mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 08:02:28 +00:00
251 lines
9.0 KiB
JavaScript
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} |