Files

251 lines
9.0 KiB
JavaScript

'use strict'
// TypeScript support for function nodes, shared across platforms. The
// `typescript` module is injected by the caller (`import * as tsc from
// 'typescript'`) so this package needs no dependency on it; browser hosts
// (monaco) reuse the same probe recipe so every platform produces identical
// type strings for the engine.
/**
* Structural type expander prepended to probes: quick info / typeToString
* would otherwise print user-declared alias/enum/interface NAMES (`Bla`,
* `Something`, `Item`) which the engine's TS-type parser cannot resolve.
* String-literal unions and string enums are rewritten into fresh literal
* unions (mapped type with template-literal values defeats alias
* preservation; the checks are non-distributive so union reassembly cannot
* re-alias) and the expansion recurses through arrays and objects. Dates are
* kept opaque so they don't explode into method soup.
*/
const FUNCTION_TYPE_EXPANDER = [
'type __grNul<T> = Extract<T, null | undefined>;',
'type __grVal<T> = Exclude<T, null | undefined>;',
'type __grExpand<T> = [__grVal<T>] extends [string] ? { [K in __grVal<T>]: `${K}` }[__grVal<T>] | __grNul<T>',
' : [__grVal<T>] extends [number] ? { [K in __grVal<T>]: K }[__grVal<T>] | __grNul<T>',
' : T extends Date ? Date',
' : T extends Array<infer U> ? Array<__grExpand<U>>',
' : T extends object ? (T extends infer O ? { [K in keyof O]: __grExpand<O[K]> } : never)',
' : T;',
].join('\n')
const RESULT_MARKER = '__fnResult'
// Numeric values are identical between the `typescript` module and monaco's
// `monaco.languages.typescript` enums, so plain numbers work in both hosts.
// target/module: ESNext, moduleResolution: NodeJs.
const FUNCTION_COMPILER_OPTIONS = {
target: 99,
module: 99,
moduleResolution: 2,
allowNonTsExtensions: true,
allowJs: true,
checkJs: true,
noEmit: true,
strict: true,
noImplicitAny: false,
lib: ['esnext'],
}
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const quoteKey = (key) => (IDENT.test(key) ? key : JSON.stringify(key))
/** Render a PolicyVariableType as TypeScript source for `FunctionInput`. */
const variableTypeToTs = (t) => {
switch (t.type) {
case 'any':
return 'any'
case 'null':
return 'null'
case 'bool':
return 'boolean'
case 'string':
return 'string'
case 'number':
return 'number'
case 'date':
return 'string'
case 'interval':
return 'string'
case 'const':
return JSON.stringify(t.value)
case 'enum':
return t.values.length > 0 ? t.values.map((value) => JSON.stringify(value)).join(' | ') : 'string'
case 'array':
return `Array<${variableTypeToTs(t.items)}>`
case 'object': {
const fields = Object.entries(t.fields).map(([key, fieldType]) => {
const optional = fieldType.type === 'nullable' ? '?' : ''
return `${quoteKey(key)}${optional}: ${variableTypeToTs(fieldType)}`
})
return fields.length > 0 ? `{ ${fields.join('; ')} }` : 'Record<string, unknown>'
}
case 'nullable':
return `${variableTypeToTs(t.inner)} | null`
default:
return 'any'
}
}
const functionInputLib = (inputTs) => `declare type FunctionInput = ${inputTs};\n`
/**
* Build the type-resolution probe: the source compiled against
* `FunctionInput = <inputTs>` with a marker declaration whose type is the
* alias-expanded awaited return type of `handler`. Hosts read the marker's
* type (quick info in monaco, checker.typeToString in node).
*/
const buildFunctionProbe = (source, inputTs) => {
const text = `type FunctionInput = ${inputTs};\n${source}\n${FUNCTION_TYPE_EXPANDER}\ndeclare const ${RESULT_MARKER}: __grExpand<Awaited<ReturnType<typeof handler>>>;\n${RESULT_MARKER};`
return { text, marker: RESULT_MARKER, markerPosition: text.lastIndexOf(RESULT_MARKER) }
}
/**
* Build the diagnostics probe: just the source behind the input prelude.
* Diagnostics starting inside the prelude belong to the host, not the user,
* and must be dropped.
*/
const buildFunctionCheck = (source, inputTs) => {
const prelude = `type FunctionInput = ${inputTs};\n`
return { text: `${prelude}${source}`, preludeLength: prelude.length }
}
const flattenMessage = (messageText) =>
typeof messageText === 'string' ? messageText : messageText.messageText
/** Filter raw TS diagnostics to user-facing ones, host-agnostic. */
const filterFunctionDiagnostics = (diagnostics, preludeLength) => {
const out = []
for (const diagnostic of diagnostics) {
// DiagnosticCategory: Warning = 0, Error = 1 — suggestions are dropped.
if (diagnostic.category !== 0 && diagnostic.category !== 1) continue
if ((diagnostic.start ?? 0) < preludeLength) continue
out.push({
severity: diagnostic.category === 1 ? 'error' : 'warning',
message: flattenMessage(diagnostic.messageText),
code: `TS${diagnostic.code}`,
start: (diagnostic.start ?? preludeLength) - preludeLength,
length: diagnostic.length ?? 0,
})
}
return out
}
const FILE_NAME = '/__zen_function__.ts'
// Programmatic CompilerOptions need full lib FILE names ('lib.esnext.d.ts');
// the short tsconfig-style names in the shared constant are for monaco.
const nodeCompilerOptions = () => ({
...FUNCTION_COMPILER_OPTIONS,
lib: FUNCTION_COMPILER_OPTIONS.lib.map((name) => (name.startsWith('lib.') ? name : `lib.${name}.d.ts`)),
})
const createService = (tsc) => {
const options = nodeCompilerOptions()
let current = { text: '', version: 0 }
const host = {
getScriptFileNames: () => [FILE_NAME],
getScriptVersion: (fileName) => (fileName === FILE_NAME ? String(current.version) : '1'),
getScriptSnapshot: (fileName) => {
if (fileName === FILE_NAME) return tsc.ScriptSnapshot.fromString(current.text)
const content = tsc.sys.readFile(fileName)
return content === undefined ? undefined : tsc.ScriptSnapshot.fromString(content)
},
getCurrentDirectory: () => tsc.sys.getCurrentDirectory(),
getCompilationSettings: () => options,
getDefaultLibFileName: (opts) => tsc.getDefaultLibFilePath(opts),
fileExists: (fileName) => fileName === FILE_NAME || tsc.sys.fileExists(fileName),
readFile: (fileName) => (fileName === FILE_NAME ? current.text : tsc.sys.readFile(fileName)),
directoryExists: tsc.sys.directoryExists,
getDirectories: tsc.sys.getDirectories,
}
const service = tsc.createLanguageService(host, tsc.createDocumentRegistry())
return {
withText(text, run) {
current = { text, version: current.version + 1 }
return run(service)
},
}
}
/**
* Create the synchronous function-type resolver for
* `new Workspace(createTypeResolver(tsc))`. Resolves the alias-expanded
* return type of each function node's `handler`, keyed and cached by
* (source, inputTs).
*/
const createTypeResolver = (tsc) => {
const runner = createService(tsc)
const cache = new Map()
return (source, inputType) => {
const inputTs = variableTypeToTs(inputType ?? { type: 'any' })
const key = `${inputTs}${source}`
if (cache.has(key)) return cache.get(key)
let resolved = null
try {
const probe = buildFunctionProbe(source, inputTs)
resolved = runner.withText(probe.text, (service) => {
const program = service.getProgram()
if (!program) return null
const checker = program.getTypeChecker()
const file = program.getSourceFile(FILE_NAME)
if (!file) return null
const marker = findMarker(tsc, file, probe.marker)
if (!marker) return null
const type = checker.getTypeAtLocation(marker)
const text = checker.typeToString(type, undefined, tsc.TypeFormatFlags.NoTruncation)
return text === '' || text === 'any' || text.includes('...') ? null : text
})
} catch {
resolved = null
}
cache.set(key, resolved)
return resolved
}
}
const findMarker = (tsc, file, marker) => {
let found = null
const visit = (node) => {
if (tsc.isIdentifier(node) && node.text === marker) found = node
tsc.forEachChild(node, visit)
}
visit(file)
return found
}
/**
* Create a diagnostics checker mirroring what the editors surface: TS
* syntactic + semantic errors/warnings for a function node's source,
* compiled against its resolved input type.
*/
const createFunctionChecker = (tsc) => {
const runner = createService(tsc)
return (source, inputType) => {
const inputTs = typeof inputType === 'string' ? inputType : variableTypeToTs(inputType ?? { type: 'any' })
try {
const check = buildFunctionCheck(source, inputTs)
return runner.withText(check.text, (service) => {
const diagnostics = [
...service.getSyntacticDiagnostics(FILE_NAME),
...service.getSemanticDiagnostics(FILE_NAME),
]
return filterFunctionDiagnostics(diagnostics, check.preludeLength)
})
} catch {
return []
}
}
}
module.exports = {
FUNCTION_TYPE_EXPANDER,
FUNCTION_COMPILER_OPTIONS,
variableTypeToTs,
functionInputLib,
buildFunctionProbe,
buildFunctionCheck,
filterFunctionDiagnostics,
createTypeResolver,
createFunctionChecker,
}