Skip to content

Commit

Permalink
feat: expose flag notations
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Jan 12, 2024
1 parent af4dfa6 commit e52efe9
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 88 deletions.
136 changes: 52 additions & 84 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { CompilerOptions, ModuleKind, ScriptTarget } from 'typescript'
import { createFSBackedSystem, createSystem, createVirtualTypeScriptEnvironment } from '@typescript/vfs'
import { objectHash } from 'ohash'
import { TwoslashError } from './error'
import type { CreateTwoSlashOptions, HandbookOptions, Position, Range, Token, TokenError, TokenWithoutPosition, TwoSlashExecuteOptions, TwoSlashInstance, TwoSlashOptions, TwoSlashReturn } from './types'
import { createPositionConverter, getIdentifierTextSpans, getOptionValueFromMap, isInRanges, parsePrimitive, removeCodeRanges, splitFiles, typesToExtension } from './utils'
import type { CompilerOptionDeclaration, CreateTwoSlashOptions, HandbookOptions, ParsedFlagNotation, Position, Range, TokenError, TokenWithoutPosition, TwoSlashExecuteOptions, TwoSlashInstance, TwoSlashOptions, TwoSlashReturn } from './types'
import { createPositionConverter, getIdentifierTextSpans, isInRanges, parseFlag, removeCodeRanges, resolveTokenPositions, splitFiles, typesToExtension } from './utils'
import { validateCodeForErrors } from './validation'

export * from './public'
Expand All @@ -19,12 +19,6 @@ const cutString = '// ---cut---\n'
const cutAfterString = '// ---cut-after---\n'
// TODO: cut range

interface OptionDeclaration {
name: string
type: 'list' | 'boolean' | 'number' | 'string' | Map<string, any>
element?: OptionDeclaration
}

export const defaultCompilerOptions: CompilerOptions = {
strict: true,
module: 99 satisfies ModuleKind.ESNext,
Expand All @@ -51,7 +45,7 @@ export const defaultHandbookOptions: HandbookOptions = {
*/
export function createTwoSlasher(createOptions: CreateTwoSlashOptions = {}): TwoSlashInstance {
const ts: TS = createOptions.tsModule!
const tsOptionDeclarations = (ts as any).optionDeclarations as OptionDeclaration[]
const tsOptionDeclarations = (ts as any).optionDeclarations as CompilerOptionDeclaration[]

// In a browser we want to DI everything, in node we can use local infra
const useFS = !!createOptions.fsMap
Expand Down Expand Up @@ -108,81 +102,63 @@ export function createTwoSlasher(createOptions: CreateTwoSlashOptions = {}): Two
...options.customTags || [],
]

function updateOptions(name: string, value: any): false | void {
const oc = tsOptionDeclarations.find(d => d.name.toLocaleLowerCase() === name.toLocaleLowerCase())
// if it's compilerOptions
if (oc) {
switch (oc.type) {
case 'number':
case 'string':
case 'boolean':
compilerOptions[oc.name] = parsePrimitive(value, oc.type)
break
case 'list': {
const elementType = oc.element!.type
const strings = value.split(',') as string[]
if (typeof elementType === 'string')
compilerOptions[oc.name] = strings.map(v => parsePrimitive(v, elementType))
else
compilerOptions[oc.name] = strings.map(v => getOptionValueFromMap(oc.name, v, elementType as Map<string, string>))

break
}
default:
// It's a map
compilerOptions[oc.name] = getOptionValueFromMap(oc.name, value, oc.type)
break
}
}
// if it's handbookOptions
else if (Object.keys(handbookOptions).includes(name)) {
// "errors" is a special case, it's a list of numbers
if (name === 'errors' && typeof value === 'string')
value = value.split(' ').map(Number);

(handbookOptions as any)[name] = value
}
// throw errors if it's not a valid compiler flag
else {
if (handbookOptions.noErrorValidation)
return false
throw new TwoslashError(
`Invalid inline compiler flag`,
`There isn't a TypeScript compiler flag called '@${name}'.`,
`This is likely a typo, you can check all the compiler flags in the TSConfig reference, or check the additional Twoslash flags in the npm page for @typescript/twoslash.`,
)
}
}
const flagNotations: ParsedFlagNotation[] = []

// #extract compiler options
Array.from(code.matchAll(reConfigBoolean)).forEach((match) => {
const index = match.index!
const name = match[1]
if (updateOptions(name, true) === false)
return
removals.push([index, index + match[0].length + 1])
flagNotations.push(
parseFlag(name, true, index, index + match[0].length + 1, customTags, tsOptionDeclarations),
)
})
Array.from(code.matchAll(reConfigValue)).forEach((match) => {
const index = match.index!
const name = match[1]
if (name === 'filename')
return
const index = match.index!
const value = match[2]
if (customTags.includes(name)) {
tokens.push({
type: 'tag',
name,
start: index + match[0].length + 1,
length: 0,
text: match[0].split(':')[1].trim(),
})
flagNotations.push(
parseFlag(name, value, index, index + match[0].length + 1, customTags, tsOptionDeclarations),
)
})

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
case 'tag':
tokens.push({
type: 'tag',
name: flag.name,
start: flag.end,
length: 0,
text: flag.value,
})
break
}
else {
if (updateOptions(name, value) === false)
return
removals.push([flag.start, flag.end])
}

if (!handbookOptions.noErrorValidation) {
const unknownFlags = flagNotations.filter(i => i.type === 'unknown')

if (unknownFlags.length) {
throw new TwoslashError(
`Unknown inline compiler flags`,
unknownFlags.map(i => `@${i.name}`).join(', '),
`This is likely a typo, you can check all the compiler flags in the TSConfig reference, or check the additional Twoslash flags in the npm page for @typescript/twoslash.`,
)
}
removals.push([index, index + match[0].length + 1])
})
}
// #endregion

const env = getEnv(compilerOptions)
Expand Down Expand Up @@ -371,27 +347,19 @@ export function createTwoSlasher(createOptions: CreateTwoSlashOptions = {}): Two
tokens = removed.tokens!
}

const resultPC = outputCode === code
? pc // reuse the converter if nothing changed
: createPositionConverter(outputCode)

const resolvedTokens = tokens
.filter(token => token.start >= 0)
.sort((a, b) => a.start - b.start) as Token[]

resolvedTokens
.forEach((token) => {
Object.assign(token, resultPC.indexToPos(token.start))
})
const indexToPos = outputCode === code
? pc.indexToPos
: createPositionConverter(outputCode).indexToPos

return {
code: outputCode,
tokens: resolvedTokens,
tokens: resolveTokenPositions(tokens, indexToPos),
meta: {
extension: ext,
compilerOptions,
handbookOptions,
removals,
flagNotations,
},

get queries() {
Expand Down
1 change: 1 addition & 0 deletions src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './types'
export {
createPositionConverter,
removeCodeRanges,
resolveTokenPositions,
} from './utils'

export {
Expand Down
19 changes: 18 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,19 @@ export interface TwoSlashReturn {
* Resolved handbook options
*/
handbookOptions: HandbookOptions
/**
* Flags which were parsed from the code
*/
flagNotations: ParsedFlagNotation[]
}
}

export interface CompilerOptionDeclaration {
name: string
type: 'list' | 'boolean' | 'number' | 'string' | Map<string, any>
element?: CompilerOptionDeclaration
}

/** Available inline flags which are not compiler flags */
export interface HandbookOptions {
/** An array of TS error codes, which you write as space separated - this is so the tool can know about unexpected errors */
Expand Down Expand Up @@ -131,7 +141,6 @@ export interface HandbookOptions {
showEmittedFile?: string
/** Declare that the TypeScript program should edit the fsMap which is passed in, this is only useful for tool-makers, defaults to false */
emit: boolean

}

export interface Position {
Expand Down Expand Up @@ -197,6 +206,14 @@ export interface TokenTag extends TokenBase {
text?: string
}

export interface ParsedFlagNotation {
type: 'compilerOptions' | 'handbookOptions' | 'tag' | 'unknown'
name: string
value: any
start: number
end: number
}

export type Token = TokenHighlight | TokenHover | TokenQuery | TokenCompletion | TokenError | TokenTag
export type TokenWithoutPosition =
| Omit<TokenHighlight, keyof Position>
Expand Down
111 changes: 110 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SourceFile } from 'typescript'
import { TwoslashError } from './error'
import type { Position, Range, TokenWithoutPosition } from './types'
import type { CompilerOptionDeclaration, ParsedFlagNotation, Position, Range, Token, TokenWithoutPosition } from './types'
import { defaultHandbookOptions } from './core'

export interface TemporaryFile {
offset: number
Expand Down Expand Up @@ -216,3 +217,111 @@ export function removeCodeRanges(code: string, removals: Range[], tokens?: Token
tokens,
}
}

/**
* - Calculate tokens `line` and `character` properties to match the code
* - Remove tokens that has negative `start` property
* - Sort tokens by `start`
*
* Note that the token items will be mutated, clone them beforehand if not desired
*/
export function resolveTokenPositions(tokens: TokenWithoutPosition[], code: string): Token[]
export function resolveTokenPositions(tokens: TokenWithoutPosition[], indexToPos: (index: number) => Position): Token[]
export function resolveTokenPositions(tokens: TokenWithoutPosition[], options: string | ((index: number) => Position)): Token[] {
const indexToPos = typeof options === 'string'
? createPositionConverter(options).indexToPos
: options

const resolvedTokens = tokens
.filter(token => token.start >= 0)
.sort((a, b) => a.start - b.start) as Token[]

resolvedTokens
.forEach(token => Object.assign(token, indexToPos(token.start)))

return resolvedTokens
}

export function parseFlag(
name: string,
value: any,
start: number,
end: number,
customTags: string[],
tsOptionDeclarations: CompilerOptionDeclaration[],
): ParsedFlagNotation {
if (customTags.includes(name)) {
return {
type: 'tag',
name,
value,
start,
end,
}
}

const compilerDecl = tsOptionDeclarations.find(d => d.name.toLocaleLowerCase() === name.toLocaleLowerCase())
// if it's compilerOptions
if (compilerDecl) {
switch (compilerDecl.type) {
case 'number':
case 'string':
case 'boolean':
return {
type: 'compilerOptions',
name: compilerDecl.name,
value: parsePrimitive(value, compilerDecl.type),
start,
end,
}
case 'list': {
const elementType = compilerDecl.element!.type
const strings = value.split(',') as string[]
const resolved = typeof elementType === 'string'
? strings.map(v => parsePrimitive(v, elementType))
: strings.map(v => getOptionValueFromMap(compilerDecl.name, v, elementType as Map<string, string>))
return {
type: 'compilerOptions',
name: compilerDecl.name,
value: resolved,
start,
end,
}
}
default: {
// It's a map
return {
type: 'compilerOptions',
name: compilerDecl.name,
value: getOptionValueFromMap(compilerDecl.name, value, compilerDecl.type),
start,
end,
}
}
}
}

// if it's handbookOptions
if (Object.keys(defaultHandbookOptions).includes(name)) {
// "errors" is a special case, it's a list of numbers
if (name === 'errors' && typeof value === 'string')
value = value.split(' ').map(Number)

return {
type: 'handbookOptions',
name,
value,
start,
end,
}
}

// unknown compiler flag
return {
type: 'unknown',
name,
value,
start,
end,
}
}
4 changes: 2 additions & 2 deletions test/results/throws/unknown_compiler_error.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

## Invalid inline compiler flag
## Unknown inline compiler flags

There isn't a TypeScript compiler flag called '@targets'.
@targets

This is likely a typo, you can check all the compiler flags in the TSConfig reference, or check the additional Twoslash flags in the npm page for @typescript/twoslash.

0 comments on commit e52efe9

Please sign in to comment.