Skip to content

Commit

Permalink
feat(twoslash-vue): improve position handling
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Jan 17, 2024
1 parent 37a5077 commit 63c5db7
Show file tree
Hide file tree
Showing 23 changed files with 1,089 additions and 150 deletions.
109 changes: 89 additions & 20 deletions packages/twoslash-vue/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { VueCompilerOptions } from '@vue/language-core'
import { SourceMap, createVueLanguage, sharedTypes } from '@vue/language-core'
import type { CompilerOptions } from 'typescript'
import ts from 'typescript'
import type {
CreateTwoslashOptions,
HandbookOptions,
ParsedFlagNotation,
Range,
TwoslashExecuteOptions,
TwoslashInstance,
Expand All @@ -12,10 +15,14 @@ import {
createPositionConverter,
createTwoslasher as createTwoslasherBase,
defaultCompilerOptions,
defaultHandbookOptions,
findFlagNotations,
findQueryMarkers,
objectHash,
removeCodeRanges,
resolveNodePositions,
} from 'twoslash'
import type { CompilerOptionDeclaration } from '../../twoslash/src/types/internal'

export interface VueSpecificOptions {
/**
Expand All @@ -41,34 +48,73 @@ export interface TwoslashVueExecuteOptions extends TwoslashExecuteOptions, VueSp
*/
export function createTwoslasher(createOptions: CreateTwoslashVueOptions = {}): TwoslashInstance {
const twoslasherBase = createTwoslasherBase(createOptions)
const cache = twoslasherBase.getCacheMap() as any as Map<string, ReturnType<typeof createVueLanguage>> | undefined
const tsOptionDeclarations = (ts as any).optionDeclarations as CompilerOptionDeclaration[]

function getVueLanguage(compilerOptions: Partial<CompilerOptions>, vueCompilerOptions: Partial<VueCompilerOptions>) {
if (!cache)
return createVueLanguage(ts, defaultCompilerOptions, vueCompilerOptions)
const key = `vue:${objectHash([compilerOptions, vueCompilerOptions])}`
if (!cache.has(key)) {
const env = createVueLanguage(ts, defaultCompilerOptions, vueCompilerOptions)
cache.set(key, env)
return env
}
return cache.get(key)!
}

function twoslasher(code: string, extension?: string, options: TwoslashVueExecuteOptions = {}) {
if (extension !== 'vue')
return twoslasherBase(code, extension, options)

// TODO: use cache like twoslasherBase
const lang = createVueLanguage(
ts,
{
...defaultCompilerOptions,
...options.compilerOptions,
},
{
...createOptions.vueCompilerOptions,
...options.vueCompilerOptions,
},
)
const vueCompilerOptions: Partial<VueCompilerOptions> = {
...createOptions.vueCompilerOptions,
...options.vueCompilerOptions,
}
const compilerOptions: Partial<CompilerOptions> = {
...defaultCompilerOptions,
...options.compilerOptions,
}
const handbookOptions: Partial<HandbookOptions> = {
...defaultHandbookOptions,
noErrorsCutted: true,
...options.handbookOptions,
}

const sourceMeta = {
removals: [] as Range[],
positionCompletions: [] as number[],
positionQueries: [] as number[],
positionHighlights: [] as Range[],
flagNotations: [] as ParsedFlagNotation[],
} satisfies Partial<TwoslashReturnMeta>

const {
customTags = createOptions.customTags || [],
} = options

const pc = createPositionConverter(code)
// we get the markers with the original code so the position is correct
findQueryMarkers(code, sourceMeta, pc.getIndexOfLineAbove)
const flagNotations = findFlagNotations(code, customTags, tsOptionDeclarations)

// #region apply flags
for (const flag of flagNotations) {
switch (flag.type) {
case 'unknown':
continue

case 'compilerOptions':
compilerOptions[flag.name] = flag.value
break
case 'handbookOptions':
// @ts-expect-error -- this is fine
handbookOptions[flag.name] = flag.value
break
}
sourceMeta.removals.push([flag.start, flag.end])
}
// #endregion

// replace non-whitespace in the already extracted markers
let strippedCode = code
Expand All @@ -79,6 +125,7 @@ export function createTwoslasher(createOptions: CreateTwoslashVueOptions = {}):
+ strippedCode.slice(end)
}

const lang = getVueLanguage(compilerOptions, vueCompilerOptions)
const fileSource = lang.createVirtualFile('index.vue', ts.ScriptSnapshot.fromString(strippedCode), 'vue')!
const fileCompiled = fileSource.getEmbeddedFiles()[0]
const typeHelpers = sharedTypes.getTypesCode(fileSource.vueCompilerOptions)
Expand All @@ -90,26 +137,32 @@ export function createTwoslasher(createOptions: CreateTwoslashVueOptions = {}):

const map = new SourceMap(fileCompiled.mappings)

function getLastGeneratedOffset(pos: number) {
const offsets = [...map.toGeneratedOffsets(pos)]
if (!offsets.length)
return undefined
return offsets[offsets.length - 1]?.[0]
}

// Pass compiled to TS file to twoslash
const result = twoslasherBase(compiled, 'tsx', {
...options,
compilerOptions: {
jsx: 4 satisfies ts.JsxEmit.ReactJSX,
jsxImportSource: 'vue',
noImplicitAny: false,
...options.compilerOptions,
...compilerOptions,
},
handbookOptions: {
noErrorsCutted: true,
...options.handbookOptions,
...handbookOptions,
keepNotations: true,
},
shouldGetHoverInfo(id) {
// ignore internal types
return !id.startsWith('__VLS')
},
positionCompletions: sourceMeta.positionCompletions
.map(p => map.toGeneratedOffset(p)![0]),
.map(p => getLastGeneratedOffset(p)!),
positionQueries: sourceMeta.positionQueries
.map(p => map.toGeneratedOffset(p)![0]),
positionHighlights: sourceMeta.positionHighlights
Expand All @@ -124,13 +177,19 @@ export function createTwoslasher(createOptions: CreateTwoslashVueOptions = {}):
.map((q) => {
if ('text' in q && q.text === 'any')
return undefined
const start = map.toSourceOffset(q.start)?.[0]
const end = map.toSourceOffset(q.start + q.length)?.[0]
if (start == null || end == null || start < 0 || end < 0 || start >= end)
const startMap = map.toSourceOffset(q.start)
if (!startMap)
return undefined
const start = startMap[0]
let end = map.toSourceOffset(q.start + q.length)?.[0]
if (end == null && startMap[1].sourceRange[0] === startMap[0])
end = startMap[1].sourceRange[1]
if (end == null || start < 0 || end < 0 || start > end)
return undefined
return Object.assign(q, {
...q,
start,
target: code.slice(start, end),
start: startMap[0],
length: end - start,
})
})
Expand Down Expand Up @@ -159,6 +218,16 @@ export function createTwoslasher(createOptions: CreateTwoslashVueOptions = {}):
result.meta.removals = mappedRemovals
}

result.nodes = result.nodes.filter((n, idx) => {
const next = result.nodes[idx + 1]
if (!next)
return true
// When multiple nodes are on the same position, we keep the last one by ignoring the previous ones
if (next.type === n.type && next.start === n.start)
return false
return true
})

result.meta.extension = 'vue'

return result
Expand Down
73 changes: 73 additions & 0 deletions packages/twoslash-vue/test/fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/// <reference types="vite/client" />

import { extname } from 'node:path'
import process from 'node:process'
import { expect, it } from 'vitest'
import type { TwoslashReturn } from 'twoslash'
import { createTwoslasher } from '../src/index'

// To add a test, create a file in the fixtures folder and it will will run through
// as though it was the codeblock.

const fixtures = import.meta.glob('./fixtures/**/*.*', { as: 'raw' })

// A temporary list of regex to match with the path of the file to test
const filters: RegExp[] = [
// /completions-files/,
]

if (process.env.CI && filters.length)
throw new Error('Should not filters fixture tests in CI, did you forget to remove them?')

const twoslasher = createTwoslasher()

Object.entries(fixtures).forEach(([path, fixture]) => {
path = path.replace(/\\/g, '/')
const expectThrows = path.includes('/throws/')
const inExt = extname(path).slice(1)
const outExt = expectThrows ? '.txt' : '.json'
const outPath = path.replace('/fixtures/', '/results/').replace(/\.[^/.]+$/, outExt)

it.skipIf(filters.length && !filters.some(f => path.match(f)))(
path,
async () => {
let result: TwoslashReturn = undefined!
try {
result = twoslasher(
await fixture(),
inExt,
{
customTags: ['annotate'],
},
)
}
catch (err: any) {
if (expectThrows) {
expect(err.message).toMatchFileSnapshot(outPath)
return
}
else {
throw err
}
}

if (expectThrows) {
throw new Error('Expected to throw')
}

else {
expect(cleanFixture(result))
.toMatchFileSnapshot(outPath)
}
},
)
})

function cleanFixture(result: TwoslashReturn) {
return JSON.stringify({
code: result.code,
nodes: result.nodes,
flags: result.meta.flagNotations,
// compilerOptions: ts.meta.compilerOptions
}, null, 2).replaceAll(process.cwd(), '[home]')
}
16 changes: 16 additions & 0 deletions packages/twoslash-vue/test/fixtures/completion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from "vue"
const count1 = ref(1)
function count2() {
return count1.value + 1
}
</script>

<template>
<div>
{{ count1 }}
// ^|
</div>
</template>
34 changes: 34 additions & 0 deletions packages/twoslash-vue/test/fixtures/example.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
// ^?
const count = ref(0)
const double = computed(() => count.value * 2)
// ^?
</script>

<script>
export default {
name: 'HelloWorld',
data() {
return {
msg: 'Hello!'
}
},
methods: {
greet() {
console.log(this.msg)
}
}
}
</script>

<template>
<button @click="count++">{{ msg }} Count is: {{ count }}</button>
// ^?
</template>
15 changes: 15 additions & 0 deletions packages/twoslash-vue/test/fixtures/query-basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
// ^?
const count = ref(0)
const double = computed(() => count.value * 2)
// ^?
</script>

<template>
<button @click="count++">count is: {{ count }}</button>
// ^?
<p>Count is: {{ count }}</p>
// ^?
</template>
Loading

0 comments on commit 63c5db7

Please sign in to comment.