Skip to content

Commit 5dcab41

Browse files
authoredOct 16, 2022
detect imported identifiers, improve sourcemaps, and filter completions by context (#299)
* detect imported identifiers * improve sourcemaps and completions * update snapshots
1 parent 2c1c288 commit 5dcab41

37 files changed

+1456
-487
lines changed
 

‎extensions/vscode-vue-language-features/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { VueVirtualDocumentProvider } from './scheme/vue'
1111
import { PluginCommunicationService } from './services/PluginCommunicationService'
1212
import { StyleLanguageProxy } from './services/StyleLanguageProxy'
1313
import { TemplateLanguageProxy } from './services/TemplateLanguageProxy'
14+
import { TriggerCompletionService } from './services/TriggerCompletionService'
1415
import { TwoSlashService } from './services/TwoSlashService'
1516
import { VirtualFileSwitcher } from './services/VirtualFileSwitcher'
1617

@@ -36,6 +37,7 @@ export async function activate(
3637
container.get(SelectVirtualFileCommand).install(),
3738
container.get(VirtualFileSwitcher).install(),
3839
container.get(TwoSlashService).install(),
40+
container.get(TriggerCompletionService).install(),
3941
new vscode.Disposable(() => container.unbindAll()),
4042
)
4143
const ts = vscode.extensions.getExtension(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { injectable } from 'inversify'
2+
import vscode, { Disposable } from 'vscode'
3+
import { Installable } from '../utils/installable'
4+
5+
@injectable()
6+
export class TriggerCompletionService
7+
extends Installable
8+
implements vscode.CompletionItemProvider
9+
{
10+
public install(): Disposable {
11+
super.install()
12+
13+
return vscode.languages.registerCompletionItemProvider(
14+
{ language: 'vue' },
15+
this,
16+
...[':', '^'],
17+
)
18+
}
19+
20+
async provideCompletionItems(
21+
document: vscode.TextDocument,
22+
position: vscode.Position,
23+
_token: vscode.CancellationToken,
24+
context: vscode.CompletionContext,
25+
): Promise<vscode.CompletionItem[]> {
26+
if (context.triggerCharacter == null) return []
27+
// TODO: check if at directive node
28+
return await vscode.commands.executeCommand(
29+
'vscode.executeCompletionItemProvider',
30+
document.uri,
31+
position,
32+
)
33+
}
34+
}

‎packages/compiler-tsx/src/template/generate.ts

+59-47
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import {
2626
camelize,
2727
capitalize,
28+
getClassNameForTagName,
2829
invariant,
2930
last,
3031
pascalCase,
@@ -117,22 +118,6 @@ export function generate(
117118
): TransformedCode {
118119
ctx = createGenerateContext(options)
119120

120-
if (ctx.used.components.size > 0) {
121-
wrap(
122-
`${annotations.templateGlobals.start}\n`,
123-
`${annotations.templateGlobals.end}\n`,
124-
() => {
125-
ctx.used.components.forEach((component) => {
126-
if (isSimpleIdentifier(component)) {
127-
writeLine(
128-
`const ${ctx.internalIdentifierPrefix}_get_identifier_${component} = () => ${component};`,
129-
)
130-
}
131-
})
132-
},
133-
)
134-
}
135-
136121
genRootNode(root)
137122
genSlotTypes(root)
138123
genAttrTypes(root)
@@ -206,14 +191,25 @@ function genRootNode(node: RootNode): void {
206191
}
207192

208193
function genKnownIdentifierGetters(ids: string[]): void {
209-
const known = ids.filter((id) => ctx.identifiers.has(id))
210-
if (known.length === 0) return
194+
ids = Array.from(
195+
new Set([...ids, ...ctx.used.components, ...ctx.used.directives]),
196+
)
197+
if (!ids.some((id) => ctx.identifiers.has(id))) return
211198
wrap(
212199
annotations.templateGlobals.start,
213200
annotations.templateGlobals.end,
214201
() => {
215202
ctx.newLine()
216-
known.forEach((id) => {
203+
ids.forEach((id) => {
204+
const knownId = ctx.identifiers.get(id)
205+
if (knownId == null) return
206+
if (
207+
!['ref', 'maybeRef', 'externalMaybeRef', 'externalRef'].includes(
208+
knownId.kind,
209+
)
210+
)
211+
return
212+
217213
writeLine(
218214
`const ${
219215
ctx.internalIdentifierPrefix
@@ -287,10 +283,17 @@ function genGlobalDeclarations(node: Node): void {
287283
if (node.scope.globals.length === 0) return
288284
writeLine(annotations.templateGlobals.start)
289285
node.scope.globals.forEach((id) => {
290-
if (ctx.identifiers.has(id)) {
291-
writeLine(
292-
`let ${id} = ${ctx.internalIdentifierPrefix}_get_identifier_${id}();`,
293-
)
286+
const knownId = ctx.identifiers.get(id)
287+
if (knownId != null) {
288+
if (
289+
['ref', 'maybeRef', 'externalMaybeRef', 'externalRef'].includes(
290+
knownId.kind,
291+
)
292+
) {
293+
writeLine(
294+
`let ${id} = ${ctx.internalIdentifierPrefix}_get_identifier_${id}();`,
295+
)
296+
}
294297
} else {
295298
writeLine(`let ${id} = ${ctx.contextIdentifier}.${id}`)
296299
}
@@ -317,9 +320,12 @@ function genElementNode(node: ElementNode): void {
317320
ctx.write(`${annotations.tsxCompletions}`)
318321
})
319322
ctx.newLine()
323+
} else {
324+
return // tag is empty, when only "<" is present
320325
}
326+
321327
if (node.isSelfClosing) {
322-
ctx.write('/>')
328+
ctx.write('/>', node.endTagLoc)
323329
return // done
324330
}
325331
ctx.write('>').newLine()
@@ -390,15 +396,15 @@ function genComponentNode(node: ComponentNode): void {
390396

391397
genDirectiveChecks(node)
392398
ctx.write('<', node.loc)
393-
ctx.write(node.resolvedName ?? node.tag, node.tagLoc).newLine()
399+
ctx.write(node.resolvedName ?? node.tag, node.tagLoc, true).newLine()
394400
indent(() => {
395401
genProps(node)
396402
ctx.write(`${annotations.tsxCompletions}`)
397403
})
398404

399405
ctx.newLine()
400406
if (node.isSelfClosing) {
401-
writeLine('/>')
407+
ctx.write('/>', node.endTagLoc).newLine()
402408
return // done
403409
}
404410
writeLine('>')
@@ -563,16 +569,13 @@ function genProps(el: ElementNode | ComponentNode): void {
563569
ctx.newLine()
564570
} else if (prop.name === 'on') {
565571
if (prop.arg == null) {
566-
ctx.write('{...(')
567-
if (prop.exp != null) {
568-
genExpressionNode(prop.exp)
572+
if (prop.exp == null) {
573+
ctx.write('on', prop.loc, true)
569574
} else {
570-
ctx.write(
571-
annotations.missingExpression,
572-
createLoc(prop.loc, prop.loc.source.length),
573-
)
575+
ctx.write('{...(')
576+
genExpressionNode(prop.exp)
577+
ctx.write(')}')
574578
}
575-
ctx.write(')}')
576579
} else {
577580
invariant(isSimpleExpressionNode(prop.arg))
578581
const id = prop.arg.content
@@ -584,6 +587,15 @@ function genProps(el: ElementNode | ComponentNode): void {
584587
)
585588

586589
const genHandler = (): void => {
590+
if (isPlainElementNode(el)) {
591+
ctx.typeGuards.push(
592+
createCompoundExpression([
593+
`$event.currentTarget instanceof `,
594+
getClassNameForTagName(el.tag),
595+
]),
596+
)
597+
}
598+
587599
ctx.write(`${getRuntimeFn(ctx.typeIdentifier, 'first')}([`).newLine()
588600
indent(() => {
589601
all.forEach((directive) => {
@@ -599,6 +611,9 @@ function genProps(el: ElementNode | ComponentNode): void {
599611
})
600612
})
601613
ctx.write('])')
614+
if (isPlainElementNode(el)) {
615+
ctx.typeGuards.pop()
616+
}
602617
}
603618

604619
if (isStaticExpression(prop.arg)) {
@@ -644,12 +659,12 @@ function genProps(el: ElementNode | ComponentNode): void {
644659
type.value?.content === 'radio')
645660
) {
646661
isCheckbox = true
647-
ctx.write('checked', prop.nameLoc)
662+
ctx.write('checked', prop.nameLoc, true)
648663
} else {
649-
ctx.write('value', prop.nameLoc)
664+
ctx.write('value', prop.nameLoc, true)
650665
}
651666
} else {
652-
ctx.write('modelValue', prop.nameLoc)
667+
ctx.write('modelValue', prop.nameLoc, true)
653668
}
654669

655670
ctx.write('={')
@@ -674,7 +689,7 @@ function genProps(el: ElementNode | ComponentNode): void {
674689
}
675690
ctx.write('}')
676691
} else if (isStaticExpression(prop.arg)) {
677-
genExpressionNode(prop.arg)
692+
ctx.write(prop.arg.content, prop.arg.loc)
678693
ctx.write('={')
679694
genExp()
680695
ctx.write('}')
@@ -737,15 +752,14 @@ function genVBindDirective(
737752
ctx.write(': true')
738753
}
739754
ctx.write('})}')
755+
} else if (prop.exp == null) {
756+
ctx.write(' ', prop.loc)
740757
} else {
741758
ctx.write('{...(')
742759
if (prop.exp != null) {
743760
genExpressionNode(prop.exp)
744761
} else {
745-
ctx.write(
746-
annotations.missingExpression,
747-
createLoc(prop.loc, prop.loc.source.length),
748-
)
762+
ctx.write(' ', createLoc(prop.loc, prop.loc.source.length))
749763
}
750764
ctx.write(')}')
751765
}
@@ -756,12 +770,9 @@ function genTextNode(node: TextNode): void {
756770
}
757771

758772
function genInterpolationNode(node: InterpolationNode): void {
759-
ctx.write('{', node.loc)
773+
ctx.write(' {', node.loc)
760774
genExpressionNode(node.content)
761-
ctx.write(
762-
'}',
763-
createLoc(node.loc, node.content.loc.end.offset - node.loc.start.offset),
764-
)
775+
ctx.write('} ', sliceLoc(node.loc, -2))
765776
}
766777

767778
function genExpressionNode(node: ExpressionNode): void {
@@ -795,6 +806,7 @@ function genExpressionNodeAsFunction(node: ExpressionNode): void {
795806
node.content.includes('$event')
796807
? ctx.write('($event) => {').newLine()
797808
: ctx.write('() => {').newLine()
809+
genTypeGuards()
798810
genSimpleExpressionNode(node)
799811
ctx.newLine().write('}')
800812
}

‎packages/compiler-tsx/src/template/parse.ts

+22-16
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
RootNode,
1010
transform,
1111
} from '@vue/compiler-core'
12-
import { last } from '@vuedx/shared'
12+
import { first, last } from '@vuedx/shared'
1313
import {
1414
createSimpleExpression,
1515
isDirectiveNode,
@@ -24,7 +24,7 @@ import './types/Node'
2424
const preprocess: NodeTransform = (node, context) => {
2525
if (isTextNode(node) && node.content.trim().startsWith('<')) {
2626
// Incomplete element tag
27-
context.replaceNode(createPlainElementNode(node.content, node.loc))
27+
context.replaceNode(createPlainElementNode(node.loc))
2828

2929
return
3030
}
@@ -42,14 +42,18 @@ const preprocess: NodeTransform = (node, context) => {
4242
node.props.forEach((prop, index) => {
4343
// remove empty modifiers
4444
if (isDirectiveNode(prop)) {
45-
const isShorthand = /^[:@.^]/.test(prop.loc.source)
46-
const nameEndOffset = isShorthand ? 1 : 2 + prop.name.length
45+
const nameEndOffset = prop.loc.source.startsWith('v-')
46+
? 2 + prop.name.length
47+
: 1
4748
let offset =
4849
prop.arg != null
4950
? prop.arg.loc.end.offset - prop.loc.start.offset
5051
: nameEndOffset
5152

5253
prop.nameLoc = sliceLoc(prop.loc, 0, nameEndOffset)
54+
if (prop.modifiers.length === 1 && first(prop.modifiers) === '') {
55+
prop.modifiers = []
56+
}
5357
prop.modifierLocs = prop.modifiers.map((modifier) => {
5458
try {
5559
offset += 1
@@ -78,11 +82,13 @@ const preprocess: NodeTransform = (node, context) => {
7882
false,
7983
createLoc(prop.loc, 1, prop.name.length - 1),
8084
)
81-
: createSimpleExpression(
85+
: prop.name.length > 1
86+
? createSimpleExpression(
8287
prop.name.slice(1),
8388
true,
8489
createLoc(prop.loc, 1, prop.name.length - 1),
85-
),
90+
)
91+
: undefined,
8692
loc: prop.loc,
8793
modifiers: [],
8894
modifierLocs: [],
@@ -105,6 +111,7 @@ const preprocess: NodeTransform = (node, context) => {
105111
node.tagLoc = createLoc(node.loc, 1, node.tag.length)
106112
if (node.isSelfClosing) {
107113
node.startTagLoc = node.loc
114+
node.endTagLoc = sliceLoc(node.loc, -2)
108115
} else {
109116
const startTagIndex = node.loc.source.indexOf(
110117
'>',
@@ -145,23 +152,22 @@ export function parse(template: string, options: ParserOptions): RootNode {
145152
return ast
146153
}
147154

148-
function createPlainElementNode(
149-
content: string,
150-
contentLoc: SourceLocation,
151-
): PlainElementNode {
152-
const source = content.trim()
155+
function createPlainElementNode(contentLoc: SourceLocation): PlainElementNode {
156+
const offset = contentLoc.source.indexOf('<')
157+
const loc = sliceLoc(contentLoc, offset)
158+
const tag = loc.source.slice(1).trim()
153159
return {
154160
type: 1 /* ELEMENT */,
155-
tag: source.slice(1),
161+
tag,
156162
tagType: 0 /* ELEMENT */,
157163
codegenNode: undefined,
158164
children: [],
159-
isSelfClosing: false,
160-
loc: contentLoc,
165+
isSelfClosing: tag.length > 0,
166+
loc,
161167
ns: 0,
162168
props: [],
163-
tagLoc: createLoc(contentLoc, 1, content.length - 1),
164-
startTagLoc: contentLoc,
169+
tagLoc: sliceLoc(loc, 1),
170+
startTagLoc: loc,
165171
endTagLoc: undefined,
166172
scope: new Scope(),
167173
}

‎packages/compiler-tsx/src/template/transforms/transformResolveComponent.ts

+61-29
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isElementNode,
1414
isSimpleIdentifier,
1515
} from '@vuedx/template-ast-types'
16+
import { KnownIdentifier } from '@vuedx/transforms'
1617
import { directives } from '../builtins'
1718
import { getRuntimeFn } from '../runtime'
1819
import type { NodeTransformContext } from '../types/NodeTransformContext'
@@ -42,19 +43,30 @@ export function createResolveComponentTransform(
4243

4344
const id = `v${pascalCase(node.name)}`
4445
node.resolvedName = id
46+
4547
if (!ctx.scope.hasIdentifier(id)) {
46-
ctx.scope.addIdentifier(id)
47-
ctx.scope.hoist(
48-
createCompoundExpression([
49-
'const ',
50-
id,
51-
` = ${h('resolveDirective')}(${ctx.contextIdentifier}, `,
52-
s(node.name),
53-
', ',
54-
s(camelCase(node.name)),
55-
');',
56-
]),
57-
)
48+
const knownId = ctx.identifiers.get(id)
49+
if (knownId == null) {
50+
ctx.scope.addIdentifier(id)
51+
ctx.scope.hoist(
52+
createCompoundExpression([
53+
'const ',
54+
id,
55+
` = ${h('resolveDirective')}(${ctx.contextIdentifier}, `,
56+
s(node.name),
57+
', ',
58+
s(camelCase(node.name)),
59+
');',
60+
]),
61+
)
62+
} else if (mayBeRef(knownId)) {
63+
ctx.scope.addIdentifier(id)
64+
ctx.scope.hoist(
65+
createCompoundExpression([
66+
`const ${id} = ${ctx.internalIdentifierPrefix}_get_identifier_${id}();`,
67+
]),
68+
)
69+
}
5870
}
5971
}
6072
})
@@ -74,23 +86,34 @@ export function createResolveComponentTransform(
7486
? id + node.tag.slice(name.length)
7587
: id
7688
if (!ctx.scope.hasIdentifier(id)) {
77-
ctx.used.components.add(id)
78-
ctx.scope.addIdentifier(id)
79-
ctx.scope.hoist(
80-
createCompoundExpression([
81-
'const ',
82-
id,
83-
` = ${h('resolveComponent')}(${resolveComponentArgs}`,
84-
isSimpleIdentifier(id)
85-
? `${ctx.internalIdentifierPrefix}_get_identifier_${id}()`
86-
: 'null',
87-
', ',
88-
s(name),
89-
', ',
90-
s(pascalCase(name)),
91-
');',
92-
]),
93-
)
89+
const knownId = ctx.identifiers.get(id)
90+
if (knownId == null || !isSimpleIdentifier(id)) {
91+
ctx.used.components.add(id)
92+
ctx.scope.addIdentifier(id)
93+
ctx.scope.hoist(
94+
createCompoundExpression([
95+
'const ',
96+
id,
97+
` = ${h('resolveComponent')}(${resolveComponentArgs}`,
98+
isSimpleIdentifier(id)
99+
? `${ctx.internalIdentifierPrefix}_get_identifier_${id}()`
100+
: 'null',
101+
', ',
102+
s(name),
103+
', ',
104+
s(pascalCase(name)),
105+
');',
106+
]),
107+
)
108+
} else if (mayBeRef(knownId)) {
109+
ctx.used.components.add(id)
110+
ctx.scope.addIdentifier(id)
111+
ctx.scope.hoist(
112+
createCompoundExpression([
113+
`const ${id} = ${ctx.internalIdentifierPrefix}_get_identifier_${id}();`,
114+
]),
115+
)
116+
}
94117
}
95118
}
96119
return undefined
@@ -103,3 +126,12 @@ export function createResolveComponentTransform(
103126
return undefined
104127
}
105128
}
129+
130+
function mayBeRef(id: KnownIdentifier): boolean {
131+
return (
132+
id.kind === 'ref' ||
133+
id.kind === 'maybeRef' ||
134+
id.kind === 'externalRef' ||
135+
id.kind === 'externalMaybeRef'
136+
)
137+
}

‎packages/compiler-tsx/src/types/TransformOptions.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SFCDescriptor } from '@vuedx/compiler-sfc'
22
import type { Cache } from '@vuedx/shared'
3+
import type { KnownIdentifier } from '@vuedx/transforms'
34

45
export interface TransformOptions {
56
/**
@@ -63,5 +64,5 @@ export interface TransformOptionsResolved extends TransformOptions {
6364
/**
6465
* Known identifiers.
6566
*/
66-
identifiers: Set<string>
67+
identifiers: Map<string, KnownIdentifier>
6768
}

‎packages/compiler-tsx/src/vue/blocks/transformScript.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import type { SFCScriptBlock } from '@vuedx/compiler-sfc'
22
import { invariant } from '@vuedx/shared'
3-
import { transformScript as transform } from '@vuedx/transforms'
3+
import {
4+
transformScript as transform,
5+
type KnownIdentifier,
6+
} from '@vuedx/transforms'
47
import type { TransformedCode } from '../../types/TransformedCode'
58
import type { TransformOptionsResolved } from '../../types/TransformOptions'
69

710
export interface ScriptBlockTransformResult extends TransformedCode {
811
exportIdentifier: string
912
name: string
1013
inheritAttrs: boolean
11-
identifiers: string[]
14+
identifiers: KnownIdentifier[]
1215
}
1316
export function transformScript(
1417
script: SFCScriptBlock | null,

‎packages/compiler-tsx/src/vue/blocks/transformScriptSetup.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { SFCScriptBlock } from '@vuedx/compiler-sfc'
22
import { invariant } from '@vuedx/shared'
3-
import { transformScriptSetup as transform } from '@vuedx/transforms'
3+
import {
4+
transformScriptSetup as transform,
5+
type KnownIdentifier,
6+
} from '@vuedx/transforms'
47
import type { TransformedCode } from '../../types/TransformedCode'
58
import type { TransformOptionsResolved } from '../../types/TransformOptions'
69

@@ -10,7 +13,7 @@ export interface ScriptSetupBlockTransformResult extends TransformedCode {
1013
propsIdentifier: string
1114
emitsIdentifier: string
1215
exposeIdentifier: string
13-
identifiers: string[]
16+
identifiers: KnownIdentifier[]
1417
exports: Record<string, string>
1518
}
1619

‎packages/compiler-tsx/src/vue/compile.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function compileWithDecodedSourceMap(
7474
isTypeScript: options.isTypeScript ?? (lang === 'ts' || lang === 'tsx'),
7575
cache,
7676
descriptor,
77-
identifiers: new Set(),
77+
identifiers: new Map(),
7878
}
7979
const builder = new SourceTransformer(options.fileName, source)
8080

@@ -95,10 +95,15 @@ export function compileWithDecodedSourceMap(
9595
() => transformScriptSetup(descriptor.scriptSetup, resolvedOptions),
9696
)
9797

98-
resolvedOptions.identifiers = new Set([
99-
...script.identifiers,
100-
...scriptSetup.identifiers,
101-
])
98+
resolvedOptions.identifiers = new Map()
99+
100+
script.identifiers.forEach((identifier) => {
101+
resolvedOptions.identifiers.set(identifier.name, identifier)
102+
})
103+
104+
scriptSetup.identifiers.forEach((identifier) => {
105+
resolvedOptions.identifiers.set(identifier.name, identifier)
106+
})
102107

103108
const template = runIfNeeded(
104109
key('template'),

‎packages/compiler-tsx/test/__snapshots__/baseline.js

+51-70
Large diffs are not rendered by default.

‎packages/compiler-tsx/test/__snapshots__/baseline.md

+51-70
Large diffs are not rendered by default.

‎packages/compiler-tsx/test/__snapshots__/vue-to-tsx.spec.ts.snap

+4-1
Original file line numberDiff line numberDiff line change
@@ -263,18 +263,21 @@ function __VueDX__render() {
263263
{...(foo)}
264264
onClick={__VueDX__TypeCheck.internal.first([
265265
() => {
266+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
266267
console.log
267268
},
268269
() => {
270+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
269271
console.log
270272
},
271273
() => {
274+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
272275
console.log
273276
},
274277
])}
275278
/*<vuedx:tsx-completions-target/>*/
276279
>
277-
{foo}
280+
{foo}
278281
</div>
279282
</>
280283
)

‎packages/compiler-tsx/test/fixtures/ts-script-template.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,21 @@ function __VueDX__render() {
3939
{...(foo)}
4040
onClick={__VueDX__TypeCheck.internal.first([
4141
() => {
42+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
4243
console.log
4344
},
4445
() => {
46+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
4547
console.log
4648
},
4749
() => {
50+
if(!($event.currentTarget instanceof HTMLDivElement)) throw new Error;
4851
console.log
4952
},
5053
])}
5154
/*<vuedx:tsx-completions-target/>*/
5255
>
53-
{foo}
56+
{foo}
5457
</div>
5558
</>
5659
)

‎packages/compiler-tsx/test/fixtures/ts-script-template.tsx.map

+1-1
Original file line numberDiff line numberDiff line change

‎packages/projectconfig/src/project/VueProject.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from './FilesystemHost'
1313
import { resolveComponents } from './resolveComponents'
1414
import { resolveDirectives } from './resolveDirectives'
15-
import * as JSON5 from 'json5'
15+
import JSON5 from 'json5'
1616
import * as Path from 'path'
1717

1818
export class VueProject {
@@ -72,6 +72,8 @@ export class VueProject {
7272
fs.watchFile(projectFile, (_fileName, event) => {
7373
if (event === FileWatcherEventKind.Changed) {
7474
this.onProjectFileChange()
75+
} else if (event === FileWatcherEventKind.Deleted) {
76+
this._config = DEFAULT_PROJECT_CONFIG
7577
}
7678
}),
7779
)

‎packages/shared/src/html.ts

+166-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const SVG_TAGS = new Set(
2424
'svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,' +
2525
'defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,' +
2626
'feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,' +
27-
'feDistanceLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,' +
27+
'feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,' +
2828
'feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,' +
2929
'fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,' +
3030
'foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,' +
@@ -42,3 +42,168 @@ const VOID_TAGS = new Set(
4242
),
4343
)
4444
export const isVoidTag = (tagName: string): boolean => VOID_TAGS.has(tagName)
45+
46+
export const HTML_TAG_NAME_TO_CLASS_NAME = {
47+
a: 'HTMLAnchorElement',
48+
area: 'HTMLAreaElement',
49+
audio: 'HTMLAudioElement',
50+
base: 'HTMLBaseElement',
51+
blockquote: 'HTMLQuoteElement',
52+
body: 'HTMLBodyElement',
53+
br: 'HTMLBRElement',
54+
button: 'HTMLButtonElement',
55+
canvas: 'HTMLCanvasElement',
56+
caption: 'HTMLTableCaptionElement',
57+
data: 'HTMLDataElement',
58+
datalist: 'HTMLDataListElement',
59+
details: 'HTMLDetailsElement',
60+
dialog: 'HTMLDialogElement',
61+
div: 'HTMLDivElement',
62+
dl: 'HTMLDListElement',
63+
embed: 'HTMLEmbedElement',
64+
fieldset: 'HTMLFieldSetElement',
65+
form: 'HTMLFormElement',
66+
h1: 'HTMLHeadingElement',
67+
head: 'HTMLHeadElement',
68+
hr: 'HTMLHRElement',
69+
html: 'HTMLHtmlElement',
70+
iframe: 'HTMLIFrameElement',
71+
img: 'HTMLImageElement',
72+
input: 'HTMLInputElement',
73+
label: 'HTMLLabelElement',
74+
legend: 'HTMLLegendElement',
75+
li: 'HTMLLIElement',
76+
link: 'HTMLLinkElement',
77+
main: 'HTMLMainElement',
78+
map: 'HTMLMapElement',
79+
menu: 'HTMLMenuElement',
80+
meta: 'HTMLMetaElement',
81+
meter: 'HTMLMeterElement',
82+
nav: 'HTMLNavElement',
83+
object: 'HTMLObjectElement',
84+
ol: 'HTMLOListElement',
85+
optgroup: 'HTMLOptGroupElement',
86+
option: 'HTMLOptionElement',
87+
output: 'HTMLOutputElement',
88+
p: 'HTMLParagraphElement',
89+
param: 'HTMLParamElement',
90+
picture: 'HTMLPictureElement',
91+
pre: 'HTMLPreElement',
92+
progress: 'HTMLProgressElement',
93+
q: 'HTMLQuoteElement',
94+
script: 'HTMLScriptElement',
95+
select: 'HTMLSelectElement',
96+
slot: 'HTMLSlotElement',
97+
source: 'HTMLSourceElement',
98+
span: 'HTMLSpanElement',
99+
style: 'HTMLStyleElement',
100+
table: 'HTMLTableElement',
101+
tbody: 'HTMLTableSectionElement',
102+
td: 'HTMLTableCellElement',
103+
template: 'HTMLTemplateElement',
104+
textarea: 'HTMLTextAreaElement',
105+
tfoot: 'HTMLTableSectionElement',
106+
th: 'HTMLTableCellElement',
107+
thead: 'HTMLTableSectionElement',
108+
title: 'HTMLTitleElement',
109+
tr: 'HTMLTableRowElement',
110+
track: 'HTMLTrackElement',
111+
ul: 'HTMLUListElement',
112+
video: 'HTMLVideoElement',
113+
}
114+
115+
export const SVG_TAG_NAME_TO_CLASS_NAME = {
116+
a: 'SVGAElement',
117+
altGlyph: 'SVGAltGlyphElement',
118+
altGlyphDef: 'SVGAltGlyphDefElement',
119+
altGlyphItem: 'SVGAltGlyphItemElement',
120+
animate: 'SVGAnimateElement',
121+
animateMotion: 'SVGAnimateMotionElement',
122+
animateTransform: 'SVGAnimateTransformElement',
123+
circle: 'SVGCircleElement',
124+
clipPath: 'SVGClipPathElement',
125+
defs: 'SVGDefsElement',
126+
desc: 'SVGDescElement',
127+
ellipse: 'SVGEllipseElement',
128+
feBlend: 'SVGFEBlendElement',
129+
feColorMatrix: 'SVGFEColorMatrixElement',
130+
feComponentTransfer: 'SVGFEComponentTransferElement',
131+
feComposite: 'SVGFECompositeElement',
132+
feConvolveMatrix: 'SVGFEConvolveMatrixElement',
133+
feDiffuseLighting: 'SVGFEDiffuseLightingElement',
134+
feDisplacementMap: 'SVGFEDisplacementMapElement',
135+
feDistantLight: 'SVGFEDistantLightElement',
136+
feDropShadow: 'SVGFEDropShadowElement',
137+
feFlood: 'SVGFEFloodElement',
138+
feFuncA: 'SVGFEFuncAElement',
139+
feFuncB: 'SVGFEFuncBElement',
140+
feFuncG: 'SVGFEFuncGElement',
141+
feFuncR: 'SVGFEFuncRElement',
142+
feGaussianBlur: 'SVGFEGaussianBlurElement',
143+
feImage: 'SVGFEImageElement',
144+
feMerge: 'SVGFEMergeElement',
145+
feMergeNode: 'SVGFEMergeNodeElement',
146+
feMorphology: 'SVGFEMorphologyElement',
147+
feOffset: 'SVGFEOffsetElement',
148+
fePointLight: 'SVGFEPointLightElement',
149+
feSpecularLighting: 'SVGFESpecularLightingElement',
150+
feSpotLight: 'SVGFESpotLightElement',
151+
feTile: 'SVGFETileElement',
152+
feTurbulence: 'SVGFETurbulenceElement',
153+
filter: 'SVGFilterElement',
154+
foreignObject: 'SVGForeignObjectElement',
155+
g: 'SVGGElement',
156+
hatch: 'SVGHatchElement',
157+
hatchpath: 'SVGHatchpathElement',
158+
image: 'SVGImageElement',
159+
line: 'SVGLineElement',
160+
linearGradient: 'SVGLinearGradientElement',
161+
marker: 'SVGMarkerElement',
162+
mask: 'SVGMaskElement',
163+
mesh: 'SVGMeshElement',
164+
meshgradient: 'SVGMeshGradientElement',
165+
meshpatch: 'SVGMeshPatchElement',
166+
meshrow: 'SVGMeshRowElement',
167+
metadata: 'SVGMetadataElement',
168+
mpath: 'SVGMPathElement',
169+
path: 'SVGPathElement',
170+
pattern: 'SVGPatternElement',
171+
polygon: 'SVGPolygonElement',
172+
polyline: 'SVGPolylineElement',
173+
radialGradient: 'SVGRadialGradientElement',
174+
rect: 'SVGRectElement',
175+
script: 'SVGScriptElement',
176+
set: 'SVGSetElement',
177+
stop: 'SVGStopElement',
178+
style: 'SVGStyleElement',
179+
svg: 'SVGSVGElement',
180+
switch: 'SVGSwitchElement',
181+
symbol: 'SVGSymbolElement',
182+
text: 'SVGTextElement',
183+
textPath: 'SVGTextPathElement',
184+
title: 'SVGTitleElement',
185+
tspan: 'SVGTSpanElement',
186+
unknown: 'SVGUnknownElement',
187+
use: 'SVGUseElement',
188+
view: 'SVGViewElement',
189+
}
190+
191+
export function getClassNameForTagName(tagName: string): string {
192+
if (isSVGTag(tagName)) {
193+
return (
194+
SVG_TAG_NAME_TO_CLASS_NAME[
195+
tagName as keyof typeof SVG_TAG_NAME_TO_CLASS_NAME
196+
] ?? 'SVGElement'
197+
)
198+
}
199+
200+
if (isHTMLTag(tagName)) {
201+
return (
202+
HTML_TAG_NAME_TO_CLASS_NAME[
203+
tagName as keyof typeof HTML_TAG_NAME_TO_CLASS_NAME
204+
] ?? 'HTMLElement'
205+
)
206+
}
207+
208+
return 'Element'
209+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { parse } from '../../compiler-tsx/src/template/parse'
2+
import { findTemplateNodeAt } from './helpers'
3+
import { NodeTypes } from './node'
4+
5+
describe(findTemplateNodeAt, () => {
6+
const options = { onError: () => {} }
7+
8+
for (const code of [
9+
'<div @',
10+
'<div v-on',
11+
'<div v-on:',
12+
'<div :',
13+
'<div ^',
14+
'<div .',
15+
'<div v-bind',
16+
'<div v-bind:',
17+
]) {
18+
it(code, () => {
19+
const ast = parse(code, options)
20+
const { node } = findTemplateNodeAt(ast, code.length)
21+
expect(node?.type).toBe(NodeTypes.DIRECTIVE)
22+
})
23+
}
24+
25+
for (const code of [
26+
'<div @click ',
27+
'<div v-on:click ',
28+
'<div :click ',
29+
'<div ^click ',
30+
'<div .click ',
31+
'<div v-bind:click ',
32+
]) {
33+
it(code, () => {
34+
const ast = parse(code, options)
35+
const { node, ancestors } = findTemplateNodeAt(ast, code.length - 1)
36+
expect(node?.type).toBe(NodeTypes.SIMPLE_EXPRESSION)
37+
const ancestor = ancestors[ancestors.length - 1]
38+
expect(ancestor.node.type).toBe(NodeTypes.DIRECTIVE)
39+
})
40+
}
41+
42+
for (const code of [
43+
'<div @[click] ',
44+
'<div v-on:[click] ',
45+
'<div :[click] ',
46+
'<div ^[click] ',
47+
'<div .[click] ',
48+
'<div v-bind:[click] ',
49+
]) {
50+
it(code, () => {
51+
const ast = parse(code, options)
52+
const { node, ancestors } = findTemplateNodeAt(ast, code.length - 2)
53+
expect(node?.type).toBe(NodeTypes.SIMPLE_EXPRESSION)
54+
const ancestor = ancestors[ancestors.length - 1]
55+
expect(ancestor.node.type).toBe(NodeTypes.DIRECTIVE)
56+
})
57+
}
58+
})
+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { first } from '@vuedx/shared'
2+
import TypeScript from 'typescript/lib/tsserverlibrary'
3+
4+
export interface KnownIdentifier {
5+
name: string
6+
kind:
7+
| 'variable'
8+
| 'function'
9+
| 'class'
10+
| 'enum'
11+
| 'ref'
12+
| 'maybeRef'
13+
| 'external'
14+
| 'externalRef'
15+
| 'externalMaybeRef'
16+
}
17+
18+
export function findIdentifiers(
19+
ts: typeof TypeScript,
20+
program: TypeScript.Program,
21+
sourceFile: TypeScript.SourceFile,
22+
): KnownIdentifier[] {
23+
const checker = program.getTypeChecker()
24+
const identifiers: KnownIdentifier[] = []
25+
26+
checker
27+
.getSymbolsInScope(
28+
sourceFile,
29+
(ts.SymbolFlags.FunctionScopedVariable |
30+
ts.SymbolFlags.BlockScopedVariable |
31+
ts.SymbolFlags.Function |
32+
ts.SymbolFlags.Class |
33+
ts.SymbolFlags.ConstEnum |
34+
ts.SymbolFlags.RegularEnum |
35+
ts.SymbolFlags.Alias) &
36+
~(
37+
ts.SymbolFlags.Interface |
38+
ts.SymbolFlags.TypeLiteral |
39+
ts.SymbolFlags.TypeParameter |
40+
ts.SymbolFlags.TypeAlias
41+
),
42+
)
43+
.forEach((sym) => {
44+
const name = sym.getName()
45+
const flags = sym.getFlags()
46+
const kind: KnownIdentifier['kind'] =
47+
(flags & ts.SymbolFlags.Function) !== 0
48+
? 'function'
49+
: (flags & ts.SymbolFlags.Class) !== 0
50+
? 'class'
51+
: (flags & ts.SymbolFlags.ConstEnum) !== 0 ||
52+
(flags & ts.SymbolFlags.RegularEnum) !== 0
53+
? 'enum'
54+
: (flags & ts.SymbolFlags.Alias) !== 0
55+
? 'externalMaybeRef'
56+
: 'maybeRef'
57+
58+
if (
59+
kind === 'maybeRef' &&
60+
sym.valueDeclaration != null &&
61+
ts.isVariableDeclaration(sym.valueDeclaration) &&
62+
sym.valueDeclaration.initializer != null
63+
) {
64+
const { initializer, type } = sym.valueDeclaration
65+
66+
if (type == null) {
67+
if (
68+
!(
69+
ts.isCallExpression(initializer) || ts.isIdentifier(initializer)
70+
) ||
71+
(ts.isCallExpression(initializer) &&
72+
ts.isIdentifier(initializer.expression) &&
73+
['defineProps', 'defineEmits'].includes(
74+
initializer.expression.getText(),
75+
))
76+
) {
77+
return identifiers.push({ name, kind: 'variable' })
78+
}
79+
}
80+
}
81+
82+
if (
83+
kind === 'externalMaybeRef' &&
84+
sym.declarations != null &&
85+
sym.declarations.length > 0
86+
) {
87+
const declaration = first(sym.declarations)
88+
if (ts.isImportClause(declaration)) {
89+
if (declaration.isTypeOnly) return
90+
if (
91+
ts.isStringLiteral(declaration.parent.moduleSpecifier) &&
92+
declaration.parent.moduleSpecifier.text.endsWith('.vue')
93+
) {
94+
return identifiers.push({ name, kind: 'external' })
95+
}
96+
} else if (ts.isNamespaceImport(declaration)) {
97+
if (declaration.parent.isTypeOnly) return
98+
} else if (ts.isImportSpecifier(declaration)) {
99+
if (declaration.isTypeOnly) return
100+
if (declaration.parent.parent.isTypeOnly) return
101+
}
102+
}
103+
104+
return identifiers.push({ name, kind })
105+
})
106+
107+
return identifiers
108+
}

‎packages/transforms/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './TransformScriptOptions'
22
export * from './tsTransformScript'
33
export * from './tsTransformScriptSetup'
4+
export * from './findIdentifiers'

‎packages/transforms/src/tsTransformScript.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import {
55
SourceTransformer,
66
} from '@vuedx/shared'
77
import type TypeScript from 'typescript/lib/tsserverlibrary'
8+
import { findIdentifiers, KnownIdentifier } from './findIdentifiers'
89
import { TransformScriptOptions } from './TransformScriptOptions'
910
export interface TransformScriptResult {
1011
code: string
1112
map: DecodedSourceMap
12-
identifiers: string[]
13+
identifiers: KnownIdentifier[]
1314
componentIdentifier: string
1415
name: string
1516
inheritAttrs: boolean
@@ -71,10 +72,7 @@ export function transformScript(
7172

7273
findNodes(sourceFile)
7374

74-
const identifiers = program
75-
.getTypeChecker()
76-
.getSymbolsInScope(sourceFile, ts.SymbolFlags.Value)
77-
.map((sym) => sym.getName())
75+
const identifiers = findIdentifiers(ts, program, sourceFile)
7876

7977
if (defaultExport != null) {
8078
const needsDefineComponent = ts.isObjectLiteralExpression(

‎packages/transforms/src/tsTransformScriptSetup.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import {
55
SourceTransformer,
66
} from '@vuedx/shared'
77
import type TypeScript from 'typescript/lib/tsserverlibrary'
8+
import { findIdentifiers, KnownIdentifier } from './findIdentifiers'
89
import { TransformScriptOptions } from './TransformScriptOptions'
910
export interface TransformScriptSetupResult {
1011
code: string
1112
map: DecodedSourceMap
12-
identifiers: string[]
13+
identifiers: KnownIdentifier[]
1314
propsIdentifier: string
1415
emitsIdentifier: string
1516
exposeIdentifier: string
@@ -90,10 +91,7 @@ export function transformScriptSetup(
9091

9192
findNodes(sourceFile)
9293

93-
const identifiers = program
94-
.getTypeChecker()
95-
.getSymbolsInScope(sourceFile, ts.SymbolFlags.Value)
96-
.map((sym) => sym.getName())
94+
const identifiers = findIdentifiers(ts, program, sourceFile)
9795

9896
const offset =
9997
firstStatement == null ? source.length : firstStatement.getFullStart()

‎packages/transforms/test/tsTransformScriptSetup.spec.ts

+71
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,77 @@ describe(transformScriptSetup, () => {
271271
const __ScriptSetup_Component = _defineComponent((_: typeof __ScriptSetup_internalProps)=> {});
272272
`)
273273
})
274+
275+
it('should detect all symbols', () => {
276+
const { identifiers } = compile(`
277+
import { aNamedImport, type TypeNamedImport } from 'vue'
278+
import * as aNamespaceImport from 'vue'
279+
import aDefaultImport from './A.vue'
280+
import type TypeDefaultImport from './B.vue'
281+
import type * as TypeNamespaceImport from './C.vue'
282+
283+
var aVar = 0
284+
let aLet: Ref<number> | number = 0
285+
const aConst = ref(0)
286+
enum aEnum {}
287+
const {aDestructuredProperty} = {}
288+
const [aDestructuredArray] = []
289+
function aFunction() {}
290+
class aClass {}
291+
interface TypeInterface {}
292+
type TypeType = {}
293+
namespace aNamespace {
294+
export const aConst = 0
295+
}
296+
`)
297+
298+
expect(identifiers).toEqual([
299+
{
300+
kind: 'function',
301+
name: 'aFunction',
302+
},
303+
{
304+
kind: 'externalMaybeRef',
305+
name: 'aNamedImport',
306+
},
307+
{
308+
kind: 'externalMaybeRef',
309+
name: 'aNamespaceImport',
310+
},
311+
{
312+
kind: 'external',
313+
name: 'aDefaultImport',
314+
},
315+
{
316+
kind: 'variable',
317+
name: 'aVar',
318+
},
319+
{
320+
kind: 'maybeRef',
321+
name: 'aLet',
322+
},
323+
{
324+
kind: 'maybeRef',
325+
name: 'aConst',
326+
},
327+
{
328+
kind: 'enum',
329+
name: 'aEnum',
330+
},
331+
{
332+
kind: 'maybeRef',
333+
name: 'aDestructuredProperty',
334+
},
335+
{
336+
kind: 'maybeRef',
337+
name: 'aDestructuredArray',
338+
},
339+
{
340+
kind: 'class',
341+
name: 'aClass',
342+
},
343+
])
344+
})
274345
})
275346

276347
function trimIndent(str: string) {

‎packages/typescript-plugin-vue/src/features/CompletionsService.ts

+129-30
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { annotations } from '@vuedx/compiler-tsx'
22
import { VueProject } from '@vuedx/projectconfig'
3-
import { lcfirst, ucfirst } from '@vuedx/shared'
3+
import { isHTMLTag, isSVGTag, last, lcfirst, ucfirst } from '@vuedx/shared'
44
import {
55
isAttributeNode,
66
isComponentNode,
77
isDirectiveNode,
88
isPlainElementNode,
9+
isSimpleExpressionNode,
910
Node,
11+
TraversalAncestors,
1012
} from '@vuedx/template-ast-types'
1113
import { VueSFCDocument } from '@vuedx/vue-virtual-textdocument'
1214
import { inject, injectable } from 'inversify'
@@ -17,6 +19,13 @@ import { LoggerService } from '../services/LoggerService'
1719
import { TemplateDeclarationsService } from '../services/TemplateDeclarationsService'
1820
import { TypescriptContextService } from '../services/TypescriptContextService'
1921

22+
type CompletionContextKind =
23+
| 'tag'
24+
| 'attribute'
25+
| 'propName'
26+
| 'eventName'
27+
| 'directiveArg'
28+
2029
@injectable()
2130
export class CompletionsService
2231
implements
@@ -62,24 +71,28 @@ export class CompletionsService
6271
)
6372
},
6473
template: (file) => {
65-
const { node, templateRange } = this.declarations.findTemplateNode(
66-
file,
67-
position,
68-
)
74+
const { node, ancestors, templateRange } =
75+
this.declarations.findTemplateNode(file, position)
76+
6977
if (node == null) return
7078
let generatedPosition = file.generatedOffsetAt(position)
7179
if (generatedPosition == null) return
7280

7381
const kind = this.detectCompletionContext(
82+
ancestors,
7483
node,
7584
position - templateRange.start,
7685
)
7786

87+
console.log(`@@@ completion`, kind, node)
88+
7889
if (kind === 'attribute') {
79-
generatedPosition = file.generated
90+
const index = file.generated
8091
.getText()
8192
.indexOf(annotations.tsxCompletions, generatedPosition)
82-
if (generatedPosition === -1) return
93+
if (index !== -1) {
94+
generatedPosition = index
95+
}
8396
}
8497

8598
return this.processCompletionInfo(
@@ -124,11 +137,20 @@ export class CompletionsService
124137
)
125138
},
126139
template: (file) => {
127-
const actualEntryName = /^(:|v-bind:)/.test(entryName)
128-
? entryName.replace(/^(:|v-bind:)/, '')
129-
: /^(@|v-on:)/.test(entryName)
130-
? `on${ucfirst(entryName.replace(/^(@|v-on:)/, ''))}`
131-
: entryName
140+
const { ancestors, node, templateRange } =
141+
this.declarations.findTemplateNode(file, position)
142+
if (node == null) return
143+
const mode = this.detectCompletionContext(
144+
ancestors,
145+
node,
146+
position - templateRange.start,
147+
)
148+
const actualEntryName =
149+
mode === 'eventName'
150+
? `on${ucfirst(entryName)}`
151+
: mode === 'attribute'
152+
? entryName.replace(/^(v-(?:bind|on):|[@:^.])/, '')
153+
: entryName
132154
const generatedPosition = file.generatedOffsetAt(position)
133155
if (generatedPosition == null) return
134156
return this.processCompletionEntryDetails(
@@ -141,6 +163,7 @@ export class CompletionsService
141163
preferences,
142164
data,
143165
),
166+
entryName,
144167
)
145168
},
146169
})
@@ -216,7 +239,7 @@ export class CompletionsService
216239

217240
public processCompletionInfo<T extends TypeScript.CompletionInfo | undefined>(
218241
info: T,
219-
kind?: 'attribute' | 'tag',
242+
kind?: CompletionContextKind,
220243
project?: VueProject,
221244
): T {
222245
if (info == null) return info
@@ -230,18 +253,75 @@ export class CompletionsService
230253
...info,
231254
entries: info.entries.flatMap((entry) => {
232255
if (entry.name.startsWith('__VueDX_')) return [] // exclude internals
233-
if (kind === 'attribute') {
256+
if (kind === 'tag') {
257+
if (
258+
entry.kind === this.ts.lib.ScriptElementKind.alias ||
259+
entry.kind === this.ts.lib.ScriptElementKind.classElement
260+
) {
261+
if (
262+
/^[A-Z]/.test(entry.name) &&
263+
entry.kindModifiers?.includes('export') === true
264+
) {
265+
return [entry]
266+
}
267+
}
268+
269+
if (
270+
entry.kind === this.ts.lib.ScriptElementKind.memberVariableElement
271+
) {
272+
// TODO: filter depending on svg/html context
273+
if (isHTMLTag(entry.name) || isSVGTag(entry.name)) return [entry]
274+
}
275+
276+
return []
277+
}
278+
279+
if (
280+
kind === 'attribute' ||
281+
kind === 'eventName' ||
282+
kind === 'propName'
283+
) {
234284
if (
235285
entry.kind === this.ts.lib.ScriptElementKind.memberVariableElement // TODO: check others
236286
) {
237287
if (entry.name.startsWith('on')) {
238-
return [
239-
{ ...entry, name: `${vOn}${lcfirst(entry.name.slice(2))}` },
240-
]
241-
} else {
242-
return [entry, { ...entry, name: `${vBind}${entry.name}` }]
288+
if (kind === 'propName') return []
289+
const arg = lcfirst(entry.name.slice(2))
290+
const name = kind === 'attribute' ? `${vOn}${arg}` : `${arg}`
291+
const insertText =
292+
entry.insertText != null
293+
? entry.isSnippet
294+
? `${name}="$1"`
295+
: name
296+
: undefined
297+
298+
return [{ ...entry, name, insertText }]
299+
} else if (kind !== 'eventName') {
300+
const attribute = {
301+
...entry,
302+
insertText:
303+
entry.insertText != null
304+
? entry.isSnippet
305+
? `${entry.name}="$1"`
306+
: entry.name
307+
: undefined,
308+
}
309+
const prop = {
310+
...entry,
311+
name: `${vBind}${entry.name}`,
312+
insertText:
313+
entry.insertText != null
314+
? entry.isSnippet
315+
? `${vBind}${entry.name}="$1"`
316+
: entry.name
317+
: undefined,
318+
}
319+
320+
return kind === 'attribute' ? [attribute, prop] : [attribute]
243321
}
244322
}
323+
324+
return []
245325
}
246326

247327
return [entry]
@@ -251,17 +331,14 @@ export class CompletionsService
251331

252332
public processCompletionEntryDetails(
253333
entryDetails: TypeScript.CompletionEntryDetails | undefined,
334+
entryName?: string,
254335
): TypeScript.CompletionEntryDetails | undefined {
255336
if (entryDetails == null) return entryDetails
256337

257338
return {
258339
...entryDetails,
340+
name: entryName ?? entryDetails.name,
259341
codeActions: entryDetails.codeActions?.flatMap((action) => {
260-
this.logger.debug(
261-
'@@@ codeActions',
262-
action,
263-
JSON.stringify(action.changes, null, 2),
264-
)
265342
const changes = this.fs.resolveAllFileTextChanges(action.changes)
266343
if (changes.length === 0) return []
267344
return { ...action, changes }
@@ -270,10 +347,13 @@ export class CompletionsService
270347
}
271348

272349
private detectCompletionContext(
350+
ancestors: TraversalAncestors,
273351
node: Node,
274352
offset: number,
275-
): 'attribute' | 'tag' | undefined {
353+
): CompletionContextKind | undefined {
276354
if (isComponentNode(node) || isPlainElementNode(node)) {
355+
if (node.tag.trim() === '') return 'tag'
356+
277357
const isOpenTag = isOffsetInSourceLocation(node.startTagLoc, offset)
278358
const isTagName = isOffsetInSourceLocation(node.tagLoc, offset)
279359

@@ -289,13 +369,32 @@ export class CompletionsService
289369
return 'attribute'
290370
}
291371
} else if (isDirectiveNode(node)) {
292-
const isDirectiveArgument = isOffsetInSourceLocation(
293-
node.arg?.loc,
294-
offset,
295-
)
372+
const isDirectiveArgument =
373+
(node.arg == null && node.exp == null && node.modifiers.length === 0) ||
374+
isOffsetInSourceLocation(node.arg?.loc, offset)
296375

297376
if (isDirectiveArgument) {
298-
return 'attribute'
377+
return node.name === 'on'
378+
? 'eventName'
379+
: node.name === 'bind'
380+
? 'propName'
381+
: 'directiveArg'
382+
}
383+
} else if (isSimpleExpressionNode(node) && ancestors.length > 0) {
384+
const directive = last(ancestors).node
385+
if (isDirectiveNode(directive)) {
386+
const isDirectiveArgument = isOffsetInSourceLocation(
387+
directive.arg?.loc,
388+
offset,
389+
)
390+
391+
if (isDirectiveArgument) {
392+
return directive.name === 'on'
393+
? 'eventName'
394+
: directive.name === 'bind'
395+
? 'propName'
396+
: 'directiveArg'
397+
}
299398
}
300399
}
301400

‎packages/typescript-plugin-vue/src/features/QuickInfoService.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ export class QuickInfoService {
9090
const textSpan = file.findOriginalTextSpan(quickInfo.textSpan)
9191
if (textSpan == null) return
9292

93-
return { ...quickInfo, textSpan }
93+
return {
94+
...quickInfo,
95+
textSpan,
96+
displayParts: quickInfo.displayParts?.map((part) => {
97+
return {
98+
...part,
99+
text: part.text.replace('__VueDX_', ''),
100+
}
101+
}),
102+
}
94103
}
95104
}

‎packages/typescript-plugin-vue/src/services/FilesystemService.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
isNotNull,
33
isProjectRuntimeFile,
44
isVueRuntimeFile,
5+
last,
56
} from '@vuedx/shared'
67
import {
78
Range,
@@ -308,8 +309,23 @@ export class FilesystemService implements Disposable {
308309
textChanges: fileTextChanges.textChanges
309310
.map((textChange) => {
310311
const span = file.findOriginalTextSpan(textChange.span)
311-
312312
if (span == null) return null
313+
const block = file.getBlockAt(span.start)
314+
if (block == null) {
315+
const previous = file.blocks.filter(
316+
(block) => block.loc.end.offset <= span.start,
317+
)
318+
const target =
319+
previous.length === 0
320+
? file.descriptor.scriptSetup ?? file.descriptor.script
321+
: last(previous)
322+
323+
if (target != null) {
324+
span.start = target.loc.end.offset
325+
span.length = 0
326+
}
327+
}
328+
313329
return { span, newText: textChange.newText }
314330
})
315331
.filter(isNotNull),

‎packages/typescript-plugin-vue/src/services/TypescriptContextService.ts

+5-44
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@ import {
88
toPosixPath,
99
} from '@vuedx/shared'
1010
import * as Path from 'path'
11-
import { TS_LANGUAGE_SERVICE } from '../constants'
1211
import type { Disposable } from '../contracts/Disposable'
1312
import type {
14-
ExtendedTSLanguageService,
1513
TSLanguageService,
1614
TSLanguageServiceHost,
1715
TSProject,
@@ -254,51 +252,18 @@ export class TypescriptContextService implements Disposable {
254252
rootDir.startsWith(key),
255253
)
256254

257-
// If key is not null, then the project must exist.
258-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
259-
if (key != null) return this.#projects.get(key)!
255+
if (key != null) {
256+
const project = this.#projects.get(key)
257+
invariant(project != null)
258+
return project
259+
}
260260

261261
const project = VueProject.create(this.serverHost, rootDir)
262-
263262
this.#projects.set(rootDir, project)
264263

265264
return project
266265
}
267266

268-
/**
269-
* Find typescript laguage service for the file.
270-
*/
271-
public getServiceFor(fileName: string): TypeScript.LanguageService | null {
272-
return this.getProjectFor(fileName)?.getLanguageService() ?? null
273-
}
274-
275-
/**
276-
* Find typescript laguage service for the file.
277-
*/
278-
public getUndecoratedServiceFor(
279-
fileName: string,
280-
): TypeScript.LanguageService | null {
281-
const service = this.getServiceFor(fileName) as ExtendedTSLanguageService
282-
if (service == null) return null
283-
if (TS_LANGUAGE_SERVICE in service) return service[TS_LANGUAGE_SERVICE]()
284-
return service
285-
}
286-
287-
public ensureProjectFor(fileName: string): void {
288-
const scriptInfo =
289-
this.projectService.getScriptInfoEnsuringProjectsUptoDate(fileName)
290-
291-
if (scriptInfo == null) {
292-
this.logger.debug(`No ScriptInfo for ${fileName}`)
293-
return
294-
}
295-
296-
this.logger.debug(
297-
`Project of ${fileName}`,
298-
scriptInfo.containingProjects.map((project) => project.getProjectName()),
299-
)
300-
}
301-
302267
/**
303268
* Find source file in typescript program
304269
*/
@@ -310,10 +275,6 @@ export class TypescriptContextService implements Disposable {
310275
}
311276
}
312277

313-
public getTypeChecker(): TypeScript.TypeChecker | null {
314-
return this.service.getProgram()?.getTypeChecker() ?? null
315-
}
316-
317278
public dispose(): void {
318279
this.#projects.forEach((project) => project.dispose())
319280
this.#projects.clear()

‎packages/vue-languageservice/src/modes/LanguageModeHTML.ts

+123-11
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { createVersionedCache } from '@vuedx/shared'
22
import {
33
ClientCapabilities,
44
DocumentContext,
5+
getDefaultHTMLDataProvider,
56
getLanguageService,
67
HTMLDocument,
8+
IAttributeData,
9+
IHTMLDataProvider,
710
LanguageService,
811
newHTMLDataProvider,
12+
Scanner,
13+
TokenType,
914
} from 'vscode-html-languageservice'
1015
import { Position, TextDocument } from 'vscode-languageserver-textdocument'
1116
import { CompletionList, Hover } from 'vscode-languageserver-types'
@@ -90,33 +95,112 @@ class BaseLanguageMode implements LanguageMode {
9095
}
9196

9297
export class LanguageModeVueHTML extends BaseLanguageMode {
98+
private readonly template: IHTMLDataProvider
99+
93100
constructor(fs: FileSystemProvider, supportMarkdown: boolean = true) {
101+
const template = newHTMLDataProvider('vue-html', VUE_HTML_EXTENSIONS)
102+
const html = getDefaultHTMLDataProvider()
103+
94104
super(
95105
'vue-html',
96106
getLanguageService({
97-
useDefaultDataProvider: true,
107+
useDefaultDataProvider: false,
98108
clientCapabilities: supportMarkdown ? markdown : plaintext,
99109
customDataProviders: [
100-
newHTMLDataProvider('vue-html', VUE_HTML_EXTENSIONS),
110+
{
111+
getId: () => 'vue-html',
112+
isApplicable: (languageId) =>
113+
languageId === 'vue-html' || languageId === 'html',
114+
provideTags: () => html.provideTags(),
115+
provideAttributes: (tag) => {
116+
if (/^[A-Z]/.test(tag)) {
117+
return template.provideAttributes(tag)
118+
}
119+
120+
return [
121+
...template.provideAttributes(tag),
122+
...html.provideAttributes(tag),
123+
]
124+
},
125+
provideValues: (tag, attribute) => {
126+
if (/^[A-Z]/.test(tag)) {
127+
return template.provideValues(tag, attribute)
128+
}
129+
130+
return [
131+
...template.provideValues(tag, attribute),
132+
...html.provideValues(tag, attribute),
133+
]
134+
},
135+
},
101136
],
102137
fileSystemProvider: fs as any,
103138
}),
104139
)
140+
141+
this.template = template
105142
}
106143

107-
public async complete(
144+
private readonly shorthands: Record<string, string> = {
145+
':': 'v-bind',
146+
'@': 'v-on',
147+
'#': 'v-slot',
148+
'.': 'v-bind',
149+
'^': 'v-bind',
150+
}
151+
152+
public async hover(
108153
document: TextDocument,
109154
position: Position,
110-
): Promise<CompletionList> {
111-
const html = this.getHtmlDocument(document)
112-
const node = html.findNodeAt(document.offsetAt(position))
113-
const isComponent = node.tag != null && /[A-Z]/.test(node.tag)
114-
const result = await super.complete(document, position)
115-
if (isComponent) {
116-
result.items = result.items.filter((item) => item.label.startsWith('v-'))
155+
): Promise<Hover | null> {
156+
const offset = document.offsetAt(position)
157+
const node = this.getHtmlDocument(document).findNodeAt(offset)
158+
const result =
159+
node.tag != null && /^[A-Z]/.test(node.tag)
160+
? null
161+
: await super.hover(document, position)
162+
if (result != null) return result
163+
const scanner = this.findToken(document, offset, TokenType.AttributeName)
164+
if (scanner == null) return null
165+
166+
const attribute = scanner.getTokenText()
167+
const directive =
168+
this.shorthands[attribute.charAt(0)] ?? attribute.replace(/:.*$/, '')
169+
if (!directive.startsWith('v-')) return null
170+
171+
const length = attribute.startsWith('v-') ? directive.length : 1
172+
if (offset > scanner.getTokenOffset() + length + 1) return null
173+
174+
const info = this.template
175+
.provideAttributes('Component')
176+
.filter((attribute) => attribute.name === directive)[0]
177+
if (info == null) return null
178+
179+
const contents = generateDocumentation(info)
180+
if (contents == null) return null
181+
182+
const range = {
183+
start: document.positionAt(scanner.getTokenOffset()),
184+
end: document.positionAt(scanner.getTokenOffset() + length),
117185
}
118186

119-
return result
187+
return { range, contents }
188+
}
189+
190+
private findToken(
191+
document: TextDocument,
192+
offset: number,
193+
type: TokenType,
194+
): Scanner | null {
195+
const node = this.getHtmlDocument(document).findNodeAt(offset)
196+
if (node.tag == null) return null
197+
const scanner = this.service.createScanner(document.getText(), node.start)
198+
let token = scanner.scan()
199+
while (token !== TokenType.EOS && scanner.getTokenEnd() < offset) {
200+
token = scanner.scan()
201+
}
202+
if (token !== type) return null
203+
return scanner
120204
}
121205
}
122206

@@ -133,3 +217,31 @@ export class LanguageModeVue extends BaseLanguageMode {
133217
)
134218
}
135219
}
220+
221+
function generateDocumentation(
222+
item: IAttributeData,
223+
): { kind: 'markdown'; value: string } | undefined {
224+
const result = {
225+
kind: 'markdown' as const,
226+
value: '',
227+
}
228+
if (item.description != null) {
229+
result.value +=
230+
typeof item.description === 'string'
231+
? item.description
232+
: item.description.value
233+
}
234+
if (item.references != null && item.references.length > 0) {
235+
if (result.value.length > 0) {
236+
result.value += '\n\n'
237+
}
238+
239+
result.value += item.references
240+
.map(function (r) {
241+
return '['.concat(r.name, '](').concat(r.url, ')')
242+
})
243+
.join(' | ')
244+
}
245+
if (result.value === '') return
246+
return result
247+
}

‎packages/vue-virtual-textdocument/src/VueSFCDocument.ts

+31-14
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ const enum MappingKey {
4949
Name,
5050
}
5151

52-
const MappingNameRE = /^<<(P|S|T)>>(\d+)(?:|\d+)$/
53-
5452
export class VueSFCDocument implements TextDocument {
5553
public readonly originalFileName: string
5654
public readonly generatedFileName: string
@@ -384,14 +382,17 @@ export class VueSFCDocument implements TextDocument {
384382
}
385383

386384
public findGeneratedTextSpan(spanInOriginalText: TextSpan): TextSpan | null {
387-
const start = this.generatedOffsetAt(spanInOriginalText.start)
385+
const block = this.getBlockAt(spanInOriginalText.start)
386+
if (block == null) return null
387+
const isZeroWidth = spanInOriginalText.length === 0
388+
const start = this.generatedOffsetAt(spanInOriginalText.start, isZeroWidth)
388389
if (start == null) return null
389-
if (spanInOriginalText.length === 0) return { start, length: 0 }
390+
if (isZeroWidth) return { start, length: 0 }
390391

391-
// TODO: collapse text span end to the end of the block
392392
const end =
393393
this.generatedOffsetAt(
394394
spanInOriginalText.start + spanInOriginalText.length,
395+
true,
395396
) ?? start
396397

397398
return { start: Math.min(start, end), length: Math.abs(end - start) }
@@ -401,10 +402,10 @@ export class VueSFCDocument implements TextDocument {
401402
kind: 'generated' | 'original',
402403
span: TextSpan,
403404
mapping: Mapping,
404-
): TextSpan | null {
405+
): (TextSpan & { mapping: 'P' | 'S' | 'T' }) | null {
405406
const name = mapping[MappingKey.Name]
406407
if (name == null) return null
407-
const result = MappingNameRE.exec(name)
408+
const result = /^<<(P|S|T)>>(\d+)(?:\|(\d+))?$/.exec(name)
408409
if (result != null) {
409410
switch (result[1]) {
410411
case 'P':
@@ -435,6 +436,7 @@ export class VueSFCDocument implements TextDocument {
435436
return {
436437
start: original + skipLength,
437438
length,
439+
mapping: 'P',
438440
}
439441
}
440442
} else {
@@ -450,6 +452,7 @@ export class VueSFCDocument implements TextDocument {
450452
return {
451453
start: generated + skipLength,
452454
length,
455+
mapping: 'P',
453456
}
454457
}
455458
}
@@ -479,7 +482,11 @@ export class VueSFCDocument implements TextDocument {
479482
) {
480483
const skipLength = Math.abs(span.start - generated)
481484
if (skipLength <= diffLength) {
482-
return { start: original, length: originalLength }
485+
return {
486+
start: original,
487+
length: originalLength,
488+
mapping: 'S',
489+
}
483490
}
484491

485492
const length = Math.min(
@@ -490,6 +497,7 @@ export class VueSFCDocument implements TextDocument {
490497
return {
491498
start: original + skipLength,
492499
length,
500+
mapping: 'S',
493501
}
494502
} else {
495503
if (
@@ -504,6 +512,7 @@ export class VueSFCDocument implements TextDocument {
504512
return {
505513
start: generated + diffLength + skipLength,
506514
length,
515+
mapping: 'S',
507516
}
508517
}
509518
}
@@ -517,31 +526,32 @@ export class VueSFCDocument implements TextDocument {
517526
const generatedLength = parseInt(result[3], 10)
518527
invariant(Number.isInteger(originalLength))
519528
invariant(Number.isInteger(generatedLength))
520-
invariant(originalLength >= generatedLength)
521529
const original = this.original.offsetAt({
522530
line: mapping[MappingKey.OriginalLine],
523531
character: mapping[MappingKey.OriginalColumn],
524532
})
525533

526-
const genreated = this.generated.offsetAt({
534+
const generated = this.generated.offsetAt({
527535
line: mapping[MappingKey.GeneratedLine],
528536
character: mapping[MappingKey.GeneratedColumn],
529537
})
530538

531539
if (kind === 'generated') {
532540
if (
533-
contains({ start: genreated, length: generatedLength }, span)
541+
contains({ start: generated, length: generatedLength }, span)
534542
) {
535543
return {
536544
start: original,
537545
length: originalLength,
546+
mapping: 'T',
538547
}
539548
}
540549
} else {
541550
if (contains({ start: original, length: originalLength }, span)) {
542551
return {
543-
start: genreated,
552+
start: generated,
544553
length: generatedLength,
554+
mapping: 'T',
545555
}
546556
}
547557
}
@@ -559,7 +569,10 @@ export class VueSFCDocument implements TextDocument {
559569
return this.generated.positionAt(generatedOffset)
560570
}
561571

562-
public generatedOffsetAt(offset: number): number | null {
572+
public generatedOffsetAt(
573+
offset: number,
574+
isZeroWidth: boolean = false,
575+
): number | null {
563576
const position = this.original.positionAt(offset)
564577
const low = this.findMapping(
565578
'original',
@@ -573,7 +586,11 @@ export class VueSFCDocument implements TextDocument {
573586
{ start: offset, length: 0 },
574587
low,
575588
)
576-
if (result != null) return result.start
589+
if (result != null) {
590+
return isZeroWidth && result.mapping === 'T'
591+
? result.start + result.length
592+
: result.start
593+
}
577594

578595
const originalStart = this.original.offsetAt({
579596
line: low[MappingKey.OriginalLine],

‎packages/vue-virtual-textdocument/test/__snapshots__/sourcemap.spec.ts.snap

+16-22
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,11 @@ const __VueDX_ctx = __VueDX_RegisterSelf(new __VueDX__ScriptSetup_Component())
121121
//#region <template>
122122
/*<vuedx:templateGlobals>*/
123123
const __VueDX__get_identifier_a = () => __VueDX_TypeCheck.internal.unref(a);
124-
const __VueDX__get_identifier_b = () => __VueDX_TypeCheck.internal.unref(b);
125124
const __VueDX__get_identifier_c = () => __VueDX_TypeCheck.internal.unref(c);
126125
/*</vuedx:templateGlobals>*/
127126
function __VueDX_render() {
128127
/*<vuedx:templateGlobals>*/
129128
let a = __VueDX__get_identifier_a();
130-
let b = __VueDX__get_identifier_b();
131129
let c = __VueDX__get_identifier_c();
132130
let $slots = __VueDX_ctx.$slots
133131
/*</vuedx:templateGlobals>*/
@@ -157,7 +155,6 @@ __VueDX_render();
157155
function __VueDX__slots() {
158156
/*<vuedx:templateGlobals>*/
159157
let a = __VueDX__get_identifier_a();
160-
let b = __VueDX__get_identifier_b();
161158
let c = __VueDX__get_identifier_c();
162159
let $slots = __VueDX_ctx.$slots
163160
/*</vuedx:templateGlobals>*/
@@ -192,7 +189,7 @@ exports[`sourcemaps directives 1`] = `
192189
a=""
193190
onA={__VueDX_TypeCheck.internal.first([
194191
onNum,
195-
^^^^^ > 5 at 1544 (39:10)
192+
^^^^^ > 5 at 1444 (36:10)
196193
])}
197194
onB={__VueDX_TypeCheck.internal.first([
198195
`;
@@ -208,7 +205,7 @@ exports[`sourcemaps directives 2`] = `
208205
a=""
209206
onA={__VueDX_TypeCheck.internal.first([
210207
onNum,
211-
^^^^^ > 5 at 1544 (39:10)
208+
^^^^^ > 5 at 1444 (36:10)
212209
])}
213210
onB={__VueDX_TypeCheck.internal.first([
214211
`;
@@ -224,7 +221,7 @@ exports[`sourcemaps directives 3`] = `
224221
a=""
225222
onA={__VueDX_TypeCheck.internal.first([
226223
onNum,
227-
^^ > 2 at 1547 (39:13)
224+
^^ > 2 at 1447 (36:13)
228225
])}
229226
onB={__VueDX_TypeCheck.internal.first([
230227
`;
@@ -240,7 +237,7 @@ exports[`sourcemaps directives 4`] = `
240237
a=""
241238
onA={__VueDX_TypeCheck.internal.first([
242239
onNum,
243-
^^ > 2 at 1547 (39:13)
240+
^^ > 2 at 1447 (36:13)
244241
])}
245242
onB={__VueDX_TypeCheck.internal.first([
246243
`;
@@ -256,7 +253,7 @@ exports[`sourcemaps directives 5`] = `
256253
a=""
257254
onA={__VueDX_TypeCheck.internal.first([
258255
onNum,
259-
^^^^^ > 5 at 1544 (39:10)
256+
^^^^^ > 5 at 1444 (36:10)
260257
])}
261258
onB={__VueDX_TypeCheck.internal.first([
262259
`;
@@ -272,7 +269,7 @@ exports[`sourcemaps directives 6`] = `
272269
a=""
273270
onA={__VueDX_TypeCheck.internal.first([
274271
onNum,
275-
^^^^^ > 5 at 1544 (39:10)
272+
^^^^^ > 5 at 1444 (36:10)
276273
])}
277274
onB={__VueDX_TypeCheck.internal.first([
278275
`;
@@ -288,7 +285,7 @@ exports[`sourcemaps directives 7`] = `
288285
a=""
289286
onA={__VueDX_TypeCheck.internal.first([
290287
onNum,
291-
^^ > 2 at 1547 (39:13)
288+
^^ > 2 at 1447 (36:13)
292289
])}
293290
onB={__VueDX_TypeCheck.internal.first([
294291
`;
@@ -304,7 +301,7 @@ exports[`sourcemaps directives 8`] = `
304301
a=""
305302
onA={__VueDX_TypeCheck.internal.first([
306303
onNum,
307-
^^ > 2 at 1547 (39:13)
304+
^^ > 2 at 1447 (36:13)
308305
])}
309306
onB={__VueDX_TypeCheck.internal.first([
310307
`;
@@ -320,7 +317,7 @@ exports[`sourcemaps directives 9`] = `
320317
onC={__VueDX_TypeCheck.internal.first([
321318
($event) => {
322319
call.a['func']($event)
323-
^^^^^^^^ > 8 at 1730 (46:16)
320+
^^^^^^^^ > 8 at 1630 (43:16)
324321
},
325322
])}
326323
`;
@@ -336,7 +333,7 @@ exports[`sourcemaps directives 10`] = `
336333
onC={__VueDX_TypeCheck.internal.first([
337334
($event) => {
338335
call.a['func']($event)
339-
^^^^^^^^ > 8 at 1730 (46:16)
336+
^^^^^^^^ > 8 at 1630 (43:16)
340337
},
341338
])}
342339
`;
@@ -352,7 +349,7 @@ exports[`sourcemaps directives 11`] = `
352349
onC={__VueDX_TypeCheck.internal.first([
353350
($event) => {
354351
call.a['func']($event)
355-
^^^^^^ > 6 at 1739 (46:25)
352+
^^^^^^ > 6 at 1639 (43:25)
356353
},
357354
])}
358355
`;
@@ -368,7 +365,7 @@ exports[`sourcemaps directives 12`] = `
368365
onC={__VueDX_TypeCheck.internal.first([
369366
($event) => {
370367
call.a['func']($event)
371-
^^^^^^ > 6 at 1739 (46:25)
368+
^^^^^^ > 6 at 1639 (43:25)
372369
},
373370
])}
374371
`;
@@ -396,9 +393,6 @@ function __VueDX_RegisterSelf<T>(ctx: T) {
396393
}
397394
const __VueDX_ctx = __VueDX_RegisterSelf(new __VueDX__Script_Component())
398395
//#region <template>
399-
/*<vuedx:templateGlobals>*/
400-
const __VueDX__get_identifier_A = () => A;
401-
/*</vuedx:templateGlobals>*/
402396
function __VueDX_render() {
403397
/*<vuedx:templateGlobals>*/
404398
let $slots = __VueDX_ctx.$slots
@@ -418,7 +412,7 @@ function __VueDX_render() {
418412
<>
419413
{$slots.a
420414
? <>
421-
{foo}
415+
{foo}
422416
</>
423417
: null
424418
}
@@ -468,7 +462,7 @@ exports[`sourcemaps v-on shorthand 1`] = `
468462
<B
469463
a=""
470464
onA={__VueDX_TypeCheck.internal.first([
471-
^ > 1 at 1464 (37:8)
465+
^^^ > 3 at 1364 (34:8)
472466
onStr,
473467
])}
474468
`;
@@ -477,14 +471,14 @@ exports[`sourcemaps v-on shorthand 2`] = `
477471
478472
<template>
479473
<B a="" @a="onStr" />
480-
> 0 at 31 (2:15)
474+
^ > 1 at 31 (2:15)
481475
</template>
482476
483477
----
484478
<B
485479
a=""
486480
onA={__VueDX_TypeCheck.internal.first([
487-
^ > 1 at 1464 (37:8)
481+
^^^ > 3 at 1364 (34:8)
488482
onStr,
489483
])}
490484
`;

‎packages/vue-virtual-textdocument/test/index.spec.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,16 @@ describe('VueSFCDocument', () => {
5252
const __VueDX_ctx = __VueDX_RegisterSelf(new __VueDX__ScriptSetup_Component())
5353
//#region <template>
5454
/*<vuedx:templateGlobals>*/
55-
const __VueDX__get_identifier_Foo = () => Foo;
56-
/*</vuedx:templateGlobals>*/
57-
/*<vuedx:templateGlobals>*/
5855
const __VueDX__get_identifier_val = () => __VueDX_TypeCheck.internal.unref(val);
56+
const __VueDX__get_identifier_Foo = () => __VueDX_TypeCheck.internal.unref(Foo);
5957
/*</vuedx:templateGlobals>*/
6058
function __VueDX_render() {
6159
/*<vuedx:templateGlobals>*/
6260
let val = __VueDX__get_identifier_val();
6361
let $slots = __VueDX_ctx.$slots
6462
/*</vuedx:templateGlobals>*/
6563
/*<vuedx:templateGlobals>*/
66-
const Foo = __VueDX_TypeCheck.internal.resolveComponent({} as unknown as __VueDX_GlobalComponents, {} as unknown as JSX.IntrinsicElements, __VueDX_ctx, __VueDX__get_identifier_Foo(), "Foo" as const, "Foo" as const);
64+
const Foo = __VueDX__get_identifier_Foo();
6765
/*</vuedx:templateGlobals>*/
6866
return (
6967
<>
@@ -74,7 +72,7 @@ describe('VueSFCDocument', () => {
7472
default: () => {
7573
return (
7674
<>
77-
{val}
75+
{val}
7876
</>
7977
)
8078
},

‎packages/vue-virtual-textdocument/test/sourcemap.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ function getTemplateFile(code: string) {
212212
lines.push(line(start))
213213
lines.push(
214214
' '.repeat(C) +
215-
'^'.repeat(range.length) +
215+
'^'.repeat(Math.max(1, range.length)) +
216216
` > ${range.length} at ${range.start} (${L}:${C})`,
217217
)
218218

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts" setup>
2+
import FixtureAttrs from './fixture-attrs.vue';
3+
import FixtureScriptSetupTypeOnly from './fixture-script-setup-type-only.vue';
4+
import FixtureScriptSetup from './fixture-script-setup.vue';
5+
import FixtureScript from './fixture-script.vue';
6+
7+
</script>
8+
9+
<template>
10+
<FixtureAttrs />
11+
<FixtureScript />
12+
<FixtureScriptSetup />
13+
<FixtureScriptSetupTypeOnly />
14+
<button />
15+
<input />
16+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script lang="ts" setup>
2+
import FixtureAttrs from './fixture-attrs.vue';
3+
</script>
4+
5+
<template>
6+
<
7+
</template>

‎test/specs/completions.spec.ts

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { createEditorContext, getProjectPath } from '../support/helpers'
2+
import { TestServer } from '../support/TestServer'
3+
4+
describe('completions', () => {
5+
const server = new TestServer()
6+
const service = createEditorContext(
7+
server,
8+
getProjectPath('typescript-diagnostics'),
9+
)
10+
11+
beforeAll(
12+
async () =>
13+
await server.sendCommand('configure', {
14+
preferences: {
15+
providePrefixAndSuffixTextForRename: true,
16+
allowRenameOfImportPath: true,
17+
includePackageJsonAutoImports: 'auto',
18+
},
19+
}),
20+
)
21+
22+
afterAll(async () => await server.close())
23+
24+
test('suggests open tag', async () => {
25+
const editor = await service.open('src/test-completions-tag.vue')
26+
editor.setCursor({ line: 5, character: 3 })
27+
28+
const response = await server.sendCommand('completionInfo', {
29+
...editor.fileAndLocation,
30+
triggerKind: 1,
31+
})
32+
assert(response.body)
33+
const completions = response.body.entries.map((entry) => entry.name)
34+
expect(completions).toContain('a')
35+
expect(completions).toContain('FixtureAttrs')
36+
})
37+
38+
test('suggests auto import', async () => {
39+
const editor = await service.open('src/test-completions-tag.vue')
40+
editor.setCursor({ line: 5, character: 3 })
41+
42+
const response = await server.sendCommand('completionInfo', {
43+
...editor.fileAndLocation,
44+
triggerKind: 1,
45+
includeExternalModuleExports: true,
46+
includeInsertTextCompletions: true,
47+
})
48+
assert(response.body)
49+
const entry = response.body.entries.find(
50+
(entry) => entry.name === 'FixtureScript',
51+
)
52+
53+
assert(entry)
54+
55+
const details = await server.sendCommand('completionEntryDetails', {
56+
...editor.fileAndLocation,
57+
entryNames: [
58+
{
59+
name: entry.name,
60+
source: entry.source,
61+
data: entry.data,
62+
},
63+
],
64+
})
65+
66+
assert(details.body)
67+
expect(details.body).toEqual([
68+
expect.objectContaining({ name: 'FixtureScript' }),
69+
])
70+
71+
const textChanges =
72+
details.body[0]?.codeActions?.[0]?.changes?.[0]?.textChanges
73+
74+
assert(textChanges)
75+
await editor.edit(textChanges)
76+
expect(editor.document.getText()).toMatchInlineSnapshot(`
77+
"<script lang="ts" setup>
78+
import FixtureAttrs from './fixture-attrs.vue';
79+
import FixtureScript from './fixture-script.vue';
80+
</script>
81+
82+
<template>
83+
<
84+
</template>
85+
"
86+
`)
87+
})
88+
89+
test('suggests attributes for components', async () => {
90+
const editor = await service.open('src/test-completions-attribute.vue')
91+
editor.setCursor({ line: 10, character: 17 })
92+
93+
const response = await server.sendCommand('completionInfo', {
94+
...editor.fileAndLocation,
95+
triggerKind: 1,
96+
})
97+
assert(response.body)
98+
const completions = response.body.entries.map((entry) => entry.name)
99+
expect(completions).toEqual([
100+
'a',
101+
':a',
102+
'b',
103+
':b',
104+
'c',
105+
':c',
106+
'class',
107+
':class',
108+
'key',
109+
':key',
110+
'@a',
111+
'@b',
112+
'@c',
113+
'ref',
114+
':ref',
115+
'style',
116+
':style',
117+
])
118+
})
119+
120+
test('suggests props for components', async () => {
121+
const editor = await service.open('src/test-completions-attribute.vue')
122+
editor.setCursor({ line: 10, character: 17 })
123+
editor.type(':')
124+
const response = await server.sendCommand('completionInfo', {
125+
...editor.fileAndLocation,
126+
triggerKind: 1,
127+
})
128+
assert(response.body)
129+
const completions = response.body.entries.map((entry) => entry.name)
130+
expect(completions).toEqual(['a', 'b', 'c', 'class', 'key', 'ref', 'style'])
131+
})
132+
133+
test('suggests props for components with prefix', async () => {
134+
const editor = await service.open('src/test-completions-attribute.vue')
135+
editor.setCursor({ line: 10, character: 17 })
136+
editor.type(':c')
137+
const response = await server.sendCommand('completionInfo', {
138+
...editor.fileAndLocation,
139+
triggerKind: 1,
140+
})
141+
assert(response.body)
142+
const completions = response.body.entries.map((entry) => entry.name)
143+
expect(completions).toContain('class')
144+
})
145+
146+
test('suggests events for components', async () => {
147+
const editor = await service.open('src/test-completions-attribute.vue')
148+
editor.setCursor({ line: 10, character: 17 })
149+
editor.type('@')
150+
const response = await server.sendCommand('completionInfo', {
151+
...editor.fileAndLocation,
152+
triggerKind: 1,
153+
})
154+
assert(response.body)
155+
const completions = response.body.entries.map((entry) => entry.name)
156+
expect(completions).toEqual([
157+
'a',
158+
'b',
159+
'c',
160+
'vnodeBeforeMount',
161+
'vnodeBeforeUnmount',
162+
'vnodeBeforeUpdate',
163+
'vnodeMounted',
164+
'vnodeUnmounted',
165+
'vnodeUpdated',
166+
])
167+
})
168+
169+
test('suggests attributes for elements', async () => {
170+
const editor = await service.open('src/test-completions-attribute.vue')
171+
editor.setCursor({ line: 14, character: 9 })
172+
173+
const response = await server.sendCommand('completionInfo', {
174+
...editor.fileAndLocation,
175+
triggerKind: 1,
176+
})
177+
assert(response.body)
178+
const completions = response.body.entries.map((entry) => entry.name)
179+
expect(completions).toContain('type')
180+
expect(completions).toContain(':type')
181+
expect(completions).toContain('@click')
182+
})
183+
184+
test('suggests attributes for elements with prefix', async () => {
185+
const editor = await service.open('src/test-completions-attribute.vue')
186+
editor.setCursor({ line: 14, character: 9 })
187+
editor.type(':ty')
188+
const response = await server.sendCommand('completionInfo', {
189+
...editor.fileAndLocation,
190+
triggerKind: 1,
191+
})
192+
assert(response.body)
193+
const completions = response.body.entries.map((entry) => entry.name)
194+
expect(completions).toContain('type')
195+
})
196+
197+
test('suggests events for elements', async () => {
198+
const editor = await service.open('src/test-completions-attribute.vue')
199+
editor.setCursor({ line: 14, character: 9 })
200+
editor.type('@')
201+
const response = await server.sendCommand('completionInfo', {
202+
...editor.fileAndLocation,
203+
triggerKind: 1,
204+
})
205+
assert(response.body)
206+
const completions = response.body.entries.map((entry) => entry.name)
207+
expect(completions).toContain('click')
208+
})
209+
210+
test('suggests events for elements with prefix', async () => {
211+
const editor = await service.open('src/test-completions-attribute.vue')
212+
editor.setCursor({ line: 14, character: 9 })
213+
editor.type('@c')
214+
const response = await server.sendCommand('completionInfo', {
215+
...editor.fileAndLocation,
216+
triggerKind: 1,
217+
})
218+
assert(response.body)
219+
const completions = response.body.entries.map((entry) => entry.name)
220+
expect(completions).toContain('click')
221+
})
222+
})
223+
224+
function assert(value: any): asserts value {
225+
expect(value).toBeTruthy()
226+
}

‎test/specs/diagnostics.spec.ts

+21-14
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('project', () => {
6767
)
6868

6969
test('v-html', async () => {
70-
const fileName = await editor.open('src/v-html.vue')
70+
const { fsPath: fileName } = await editor.open('src/v-html.vue')
7171
const { semantic } = await editor.getDiagnostics(fileName)
7272
expect(semantic.map((diagnostic) => diagnostic.text))
7373
.toMatchInlineSnapshot(`
@@ -81,7 +81,7 @@ describe('project', () => {
8181
})
8282

8383
test('v-memo', async () => {
84-
const fileName = await editor.open('src/v-memo.vue')
84+
const { fsPath: fileName } = await editor.open('src/v-memo.vue')
8585
const { semantic } = await editor.getDiagnostics(fileName)
8686
expect(semantic.map((diagnostic) => diagnostic.text))
8787
.toMatchInlineSnapshot(`
@@ -93,7 +93,7 @@ describe('project', () => {
9393
})
9494

9595
test('v-model', async () => {
96-
const fileName = await editor.open('src/v-model.vue')
96+
const { fsPath: fileName } = await editor.open('src/v-model.vue')
9797
const { semantic } = await editor.getDiagnostics(fileName)
9898
expect(semantic.map((diagnostic) => diagnostic.text))
9999
.toMatchInlineSnapshot(`
@@ -104,12 +104,21 @@ describe('project', () => {
104104
Type 'number' is not assignable to type 'string'.",
105105
"Type '{ modelValue: number | undefined; }' is not assignable to type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ foo?: unknown; bar?: unknown; } & {} & { bar?: string | number | undefined; foo?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })),...'.
106106
Property 'modelValue' does not exist on type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ foo?: unknown; bar?: unknown; } & {} & { bar?: string | number | undefined; foo?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })),...'.",
107+
"Type '{ foo: string | undefined; }' is not assignable to type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.
108+
Property 'foo' does not exist on type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.",
109+
"Type '{ foo: number | undefined; }' is not assignable to type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.
110+
Property 'foo' does not exist on type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.",
111+
"Type 'number | undefined' is not assignable to type 'string | undefined'.",
112+
"Type '{ bar: string | undefined; }' is not assignable to type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.
113+
Property 'bar' does not exist on type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.",
114+
"Type '{ bar: number | undefined; }' is not assignable to type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.
115+
Property 'bar' does not exist on type 'ReservedProps & Partial<{}> & Omit<((Readonly<{ modelValue?: unknown; } & {} & { modelValue?: string | undefined; }> & { [x: \`on\${Capitalize<string>}\`]: ((...args: any[]) => any) | undefined; }) | (Readonly<...> & { ...; })) & (VNodeProps & ... 3 more ... & ({ ...; } | { ...; })), never>'.",
107116
]
108117
`)
109118
})
110119

111120
test('v-model-checkbox', async () => {
112-
const fileName = await editor.open('src/v-model-checkbox.vue')
121+
const { fsPath: fileName } = await editor.open('src/v-model-checkbox.vue')
113122
const { semantic } = await editor.getDiagnostics(fileName)
114123
expect(semantic.map((diagnostic) => diagnostic.text))
115124
.toMatchInlineSnapshot(`
@@ -118,14 +127,12 @@ describe('project', () => {
118127
Type '"yes"' is not assignable to type 'Booleanish | undefined'.",
119128
"Type '"yes" | "no"' is not assignable to type 'Booleanish | undefined'.",
120129
"Type '"yes" | "no"' is not assignable to type 'Booleanish | undefined'.",
121-
"Object is possibly 'null'.",
122-
"Property 'checked' does not exist on type 'EventTarget'.",
123130
]
124131
`)
125132
})
126133

127134
test('v-model-input', async () => {
128-
const fileName = await editor.open('src/v-model-input.vue')
135+
const { fsPath: fileName } = await editor.open('src/v-model-input.vue')
129136
const { semantic } = await editor.getDiagnostics(fileName)
130137
expect(semantic.map((diagnostic) => diagnostic.text))
131138
.toMatchInlineSnapshot(`
@@ -139,15 +146,15 @@ describe('project', () => {
139146
})
140147

141148
test('v-model-select', async () => {
142-
const fileName = await editor.open('src/v-model-select.vue')
149+
const { fsPath: fileName } = await editor.open('src/v-model-select.vue')
143150
const { semantic } = await editor.getDiagnostics(fileName)
144151
expect(
145152
semantic.map((diagnostic) => diagnostic.text),
146153
).toMatchInlineSnapshot(`[]`)
147154
})
148155

149156
test('v-on-native', async () => {
150-
const fileName = await editor.open('src/v-on-native.vue')
157+
const { fsPath: fileName } = await editor.open('src/v-on-native.vue')
151158
const { semantic } = await editor.getDiagnostics(fileName)
152159
expect(semantic.map((diagnostic) => diagnostic.text))
153160
.toMatchInlineSnapshot(`
@@ -165,7 +172,7 @@ describe('project', () => {
165172
})
166173

167174
test('v-on', async () => {
168-
const fileName = await editor.open('src/v-on.vue')
175+
const { fsPath: fileName } = await editor.open('src/v-on.vue')
169176
const { semantic } = await editor.getDiagnostics(fileName)
170177
expect(semantic.map((diagnostic) => diagnostic.text))
171178
.toMatchInlineSnapshot(`
@@ -186,7 +193,7 @@ describe('project', () => {
186193
})
187194

188195
test('v-once', async () => {
189-
const fileName = await editor.open('src/v-once.vue')
196+
const { fsPath: fileName } = await editor.open('src/v-once.vue')
190197
const { semantic } = await editor.getDiagnostics(fileName)
191198
expect(semantic.map((diagnostic) => diagnostic.text))
192199
.toMatchInlineSnapshot(`
@@ -202,15 +209,15 @@ describe('project', () => {
202209
})
203210

204211
test('v-pre', async () => {
205-
const fileName = await editor.open('src/v-pre.vue')
212+
const { fsPath: fileName } = await editor.open('src/v-pre.vue')
206213
const { semantic } = await editor.getDiagnostics(fileName)
207214
expect(
208215
semantic.map((diagnostic) => diagnostic.text),
209216
).toMatchInlineSnapshot(`[]`)
210217
})
211218

212219
test('v-show', async () => {
213-
const fileName = await editor.open('src/v-show.vue')
220+
const { fsPath: fileName } = await editor.open('src/v-show.vue')
214221
const { semantic } = await editor.getDiagnostics(fileName)
215222
expect(semantic.map((diagnostic) => diagnostic.text))
216223
.toMatchInlineSnapshot(`
@@ -223,7 +230,7 @@ describe('project', () => {
223230
})
224231

225232
test('v-text', async () => {
226-
const fileName = await editor.open('src/v-text.vue')
233+
const { fsPath: fileName } = await editor.open('src/v-text.vue')
227234
const { semantic } = await editor.getDiagnostics(fileName)
228235
expect(semantic.map((diagnostic) => diagnostic.text))
229236
.toMatchInlineSnapshot(`

‎test/support/TestServer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export class TestServer {
278278
event: string,
279279
check: (event: any) => boolean = () => true,
280280
): Promise<Proto.Event> {
281-
return new Promise((resolve) => {
281+
return await new Promise((resolve) => {
282282
this.onceEventHandlers.push((payload) => {
283283
if (payload.event === event && check(payload)) {
284284
resolve(payload)

‎test/support/helpers.ts

+126-86
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import * as _FS from 'fs'
22
import * as Path from 'path'
3-
import type { CodeEdit, Location, TextSpan } from 'typescript/lib/protocol'
3+
import type {
4+
CodeEdit,
5+
FileLocationRequestArgs,
6+
Location,
7+
TextSpan,
8+
} from 'typescript/lib/protocol'
49
import {
510
Position,
11+
Range,
612
TextDocument,
713
TextEdit,
814
} from 'vscode-languageserver-textdocument'
@@ -105,10 +111,10 @@ export async function findAllPositionsIn(
105111
return pos
106112
}
107113

108-
const cache = new Map<string, TextDocument>()
114+
const documents = new Map<string, TextDocument>()
109115

110116
export async function getTextDocument(file: string): Promise<TextDocument> {
111-
return cache.get(file) ?? (await createTextDocument(file))
117+
return documents.get(file) ?? (await createTextDocument(file))
112118
}
113119

114120
async function createTextDocument(file: string): Promise<TextDocument> {
@@ -120,7 +126,7 @@ async function createTextDocument(file: string): Promise<TextDocument> {
120126
content,
121127
)
122128

123-
cache.set(file, document)
129+
documents.set(file, document)
124130

125131
return document
126132
}
@@ -143,6 +149,116 @@ export function getProjectPath(
143149
return Path.resolve(__dirname, '../../samples', version, name)
144150
}
145151

152+
export class Editor {
153+
private readonly server: TestServer
154+
155+
private _document: TextDocument
156+
private _selections: Range[]
157+
158+
public readonly fsPath: string
159+
160+
public get document(): TextDocument {
161+
return this._document
162+
}
163+
164+
/**
165+
* 0-based line number and character offset
166+
*/
167+
public get cursor(): Position {
168+
const selection = this._selections[0]
169+
if (selection == null) throw new Error('No cursor set')
170+
return selection.end
171+
}
172+
173+
public get fileAndLocation(): FileLocationRequestArgs {
174+
const location = positionToLocation(this.cursor)
175+
return {
176+
file: this.fsPath,
177+
line: location.line,
178+
offset: location.offset,
179+
}
180+
}
181+
182+
public get selection(): Range {
183+
const selection = this._selections[0]
184+
if (selection == null) throw new Error('No cursor set')
185+
return selection
186+
}
187+
188+
constructor(server: TestServer, fsPath: string, document: TextDocument) {
189+
this.server = server
190+
this.fsPath = fsPath
191+
this._document = document
192+
const end = this._document.positionAt(this._document.getText().length)
193+
this._selections = [{ start: end, end }]
194+
}
195+
196+
async type(newText: string): Promise<void> {
197+
await this._apply(
198+
this._selections.map((range) => ({
199+
range,
200+
newText,
201+
})),
202+
)
203+
}
204+
205+
private _transform(offset: number, edits: TextEdit[]): number {
206+
edits = edits
207+
.slice()
208+
.sort((a, b) => a.range.start.line - b.range.start.line)
209+
210+
let delta = 0
211+
for (const edit of edits) {
212+
const rangeOffset = this.document.offsetAt(edit.range.start)
213+
if (offset < rangeOffset) break
214+
delta += edit.newText.length - this.document.getText(edit.range).length
215+
}
216+
217+
return offset + delta
218+
}
219+
220+
private async _apply(edits: TextEdit[]): Promise<void> {
221+
const offset = this._transform(this.document.offsetAt(this.cursor), edits)
222+
const content = TextDocument.applyEdits(this._document, edits)
223+
this._document = TextDocument.update(
224+
this._document,
225+
[{ text: content }],
226+
this._document.version + 1,
227+
)
228+
229+
documents.set(this.fsPath, this._document)
230+
231+
this.setCursor(this.document.positionAt(offset))
232+
233+
await this.server.sendCommand('updateOpen', {
234+
changedFiles: [
235+
{
236+
fileName: this.fsPath,
237+
textChanges: edits.map(textEditToCodeEdit),
238+
},
239+
],
240+
})
241+
}
242+
243+
async edit(textChanges: CodeEdit[]): Promise<void> {
244+
await this._apply(textChanges.map(codeEditToTextEdit))
245+
}
246+
247+
/**
248+
* 0-based line number and character offset
249+
*/
250+
setSelection(cursor: Range): void {
251+
this._selections = [cursor]
252+
}
253+
254+
/**
255+
* 0-based line number and character offset
256+
*/
257+
setCursor(cursor: Position): void {
258+
this.setSelection({ start: cursor, end: cursor })
259+
}
260+
}
261+
146262
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
147263
export function createEditorContext(server: TestServer, rootDir: string) {
148264
const openFiles = new Set<string>()
@@ -164,92 +280,13 @@ export function createEditorContext(server: TestServer, rootDir: string) {
164280
})),
165281
})
166282

167-
return absFilePath
168-
},
169-
170-
async edit(fileName: string, textOrEdits: string | TextEdit[]) {
171-
const absFilePath = abs(fileName)
172-
const document = await getTextDocument(absFilePath)
173-
const edits: TextEdit[] =
174-
typeof textOrEdits === 'string'
175-
? [
176-
{
177-
newText: textOrEdits,
178-
range: {
179-
start: document.positionAt(0),
180-
end: document.positionAt(document.getText().length),
181-
},
182-
},
183-
]
184-
: textOrEdits
185-
const content = TextDocument.applyEdits(document, edits)
186-
187-
cache.set(
188-
fileName,
189-
TextDocument.update(
190-
document,
191-
[{ text: content }],
192-
document.version + 1,
193-
),
194-
)
195-
196-
await server.sendCommand('updateOpen', {
197-
changedFiles: [
198-
{
199-
fileName: absFilePath,
200-
textChanges: edits.map((edit) => textEditToCodeEdit(edit)),
201-
},
202-
],
203-
})
204-
},
205-
206-
async replaceIn(
207-
fileName: string,
208-
search: string | RegExp,
209-
replace: (...args: string[]) => string,
210-
) {
211-
const absFilePath = abs(fileName)
212-
const document = await getTextDocument(absFilePath)
213-
const content = document.getText()
214-
215-
if (typeof search === 'string') {
216-
const index = content.indexOf(search)
217-
if (index < 0) return false
218-
await this.edit(fileName, [
219-
{
220-
range: {
221-
start: document.positionAt(index),
222-
end: document.positionAt(index + search.length),
223-
},
224-
newText: replace(),
225-
},
226-
])
227-
return true
228-
} else {
229-
const edits: TextEdit[] = []
230-
let matches: RegExpExecArray | null
231-
while ((matches = search.exec(content)) != null) {
232-
const text = matches[0] as string
233-
const index = matches.index
234-
edits.push({
235-
range: {
236-
start: document.positionAt(index),
237-
end: document.positionAt(index + text.length),
238-
},
239-
newText: replace(...matches),
240-
})
241-
}
242-
if (edits.length > 0) {
243-
await this.edit(fileName, edits)
244-
}
245-
return false
246-
}
283+
return new Editor(server, absFilePath, await getTextDocument(absFilePath))
247284
},
248285

249286
async close(fileName: string) {
250287
const absFilePath = abs(fileName)
251288

252-
cache.delete(absFilePath)
289+
documents.delete(absFilePath)
253290
openFiles.delete(absFilePath)
254291

255292
const result = await server.sendCommand('updateOpen', {
@@ -262,17 +299,20 @@ export function createEditorContext(server: TestServer, rootDir: string) {
262299

263300
if (!result.success) throw new Error(result.message)
264301
},
302+
265303
async closeAll() {
266304
const result = await server.sendCommand('updateOpen', {
267305
closedFiles: Array.from(openFiles),
268306
})
269307
openFiles.forEach((fileName) => {
270-
cache.delete(fileName)
308+
documents.delete(fileName)
271309
})
272310
if (!result.success) throw new Error(result.message)
273311
await server.flush(['events', 'requests', 'responses'])
312+
documents.clear()
274313
openFiles.clear()
275314
},
315+
276316
async getCompilerDiagnostics(fileName: string) {
277317
const info = await api.getProjectInfo(api.abs(fileName))
278318

0 commit comments

Comments
 (0)
Please sign in to comment.