import * as matches from "ts-matches"; import { Parser, Validator } from "ts-matches"; import { UnionSelectKey, UnionValueKey, ValueSpec as ValueSpecAny, InputSpec, } from "../config/configTypes"; const { string, some, arrayOf, object, dictionary, unknown, number, literals, boolean, } = matches; type TypeBoolean = "boolean"; type TypeString = "string"; type TypeTextarea = "textarea"; type TypeNumber = "number"; type TypeObject = "object"; type TypeList = "list"; type TypeSelect = "select"; type TypeMultiselect = "multiselect"; type TypeUnion = "union"; // prettier-ignore type GuardDefaultRequired = A extends { default: unknown } ? Type : A extends { required: false } ? Type : A extends { required: true } ? Type | null | undefined : Type // prettier-ignore type GuardNumber = A extends { type: TypeNumber } ? GuardDefaultRequired : unknown // prettier-ignore type GuardString = A extends { type: TypeString } ? GuardDefaultRequired : unknown // prettier-ignore type GuardTextarea = A extends { type: TypeTextarea } ? GuardDefaultRequired : unknown // prettier-ignore type GuardBoolean = A extends { type: TypeBoolean } ? GuardDefaultRequired : unknown // prettier-ignore type GuardObject = A extends { type: TypeObject, spec: infer B } ? ( B extends Record ? { [K in keyof B & string]: _> } : { _error: "Invalid Spec" } ) : unknown // prettier-ignore export type GuardList = A extends { type: TypeList, spec?: { type: infer B, spec?: infer C } } ? Array & ({ type: B, spec: C })>> : A extends { type: TypeList, spec?: { type: infer B } } ? Array & ({ type: B })>> : unknown // prettier-ignore type GuardSelect = A extends { type: TypeSelect, values: infer B } ? ( B extends Record ? keyof B : never ) : unknown // prettier-ignore type GuardMultiselect = A extends { type: TypeMultiselect, values: infer B} ?(keyof B)[] : unknown // prettier-ignore type VariantValue = A extends { name: string, spec: infer B } ? TypeFromProps : never // prettier-ignore type GuardUnion = A extends { type: TypeUnion, variants: infer Variants & Record } ? ( _<{[key in keyof Variants]: {[k in UnionSelectKey]: key} & {[k in UnionValueKey]: VariantValue}}[keyof Variants]> ) : unknown type _ = T; export type GuardAll = GuardNumber & GuardString & GuardTextarea & GuardBoolean & GuardObject & GuardList & GuardUnion & GuardSelect & GuardMultiselect; // prettier-ignore export type TypeFromProps = A extends Record ? { [K in keyof A & string]: _> } : unknown; const isType = object({ type: string }); const matchVariant = object({ name: string, spec: unknown, }); const recordString = dictionary([string, unknown]); const matchDefault = object({ default: unknown }); const matchRequired = object({ required: literals(false) }); const rangeRegex = /(\[|\()(\*|(\d|\.)+),(\*|(\d|\.)+)(\]|\))/; const matchRange = object({ range: string }); const matchIntegral = object({ integral: literals(true) }); const matchSpec = object({ spec: recordString }); const matchUnion = object({ variants: dictionary([string, matchVariant]), }); const matchValues = object({ values: dictionary([string, string]), }); function charRange(value = "") { const split = value .split("-") .filter(Boolean) .map((x) => x.charCodeAt(0)); if (split.length < 1) return null; if (split.length === 1) return [split[0], split[0]]; return [split[0], split[1]]; } /** * @param generate.charset Pattern like "a-z" or "a-z,1-5" * @param generate.len Length to make random variable * @param param1 * @returns */ export function generateDefault( generate: { charset: string; len: number }, { random = () => Math.random() } = {}, ) { const validCharSets: number[][] = generate.charset .split(",") .map(charRange) .filter(Array.isArray); if (validCharSets.length === 0) { throw new Error("Expecing that we have a valid charset"); } const max = validCharSets.reduce( (acc, x) => x.reduce((x, y) => Math.max(x, y), acc), 0, ); let i = 0; const answer: string[] = Array(generate.len); while (i < generate.len) { const nextValue = Math.round(random() * max); const inRange = validCharSets.reduce( (acc, [lower, upper]) => acc || (nextValue >= lower && nextValue <= upper), false, ); if (!inRange) continue; answer[i] = String.fromCharCode(nextValue); i++; } return answer.join(""); } export function matchNumberWithRange(range: string) { const matched = rangeRegex.exec(range); if (!matched) return number; const [, left, leftValue, , rightValue, , right] = matched; return number .validate( leftValue === "*" ? (_) => true : left === "[" ? (x) => x >= Number(leftValue) : (x) => x > Number(leftValue), leftValue === "*" ? "any" : left === "[" ? `greaterThanOrEqualTo${leftValue}` : `greaterThan${leftValue}`, ) .validate( // prettier-ignore rightValue === "*" ? (_) => true : right === "]" ? (x) => x <= Number(rightValue) : (x) => x < Number(rightValue), // prettier-ignore rightValue === "*" ? "any" : right === "]" ? `lessThanOrEqualTo${rightValue}` : `lessThan${rightValue}`, ); } function withIntegral(parser: Parser, value: unknown) { if (matchIntegral.test(value)) { return parser.validate(Number.isInteger, "isIntegral"); } return parser; } function withRange(value: unknown) { if (matchRange.test(value)) { return matchNumberWithRange(value.range); } return number; } const isGenerator = object({ charset: string, len: number, }).test; function defaultRequired(parser: Parser, value: unknown) { if (matchDefault.test(value)) { if (isGenerator(value.default)) { return parser.defaultTo( parser.unsafeCast(generateDefault(value.default)), ); } return parser.defaultTo(value.default); } if (!matchRequired.test(value)) return parser.optional(); return parser; } /** * InputSpec: Tells the UI how to ask for information, verification, and will send the service a config in a shape via the spec. * ValueSpecAny: This is any of the values in a config spec. * * Use this when we want to convert a value spec any into a parser for what a config will look like * @param value * @returns */ export function guardAll( value: A, ): Parser> { if (!isType.test(value)) { return unknown as any; } switch (value.type) { case "boolean": return defaultRequired(boolean, value) as any; case "string": return defaultRequired(string, value) as any; case "textarea": return defaultRequired(string, value) as any; case "number": return defaultRequired( withIntegral(withRange(value), value), value, ) as any; case "object": if (matchSpec.test(value)) { return defaultRequired(typeFromProps(value.spec), value) as any; } return unknown as any; case "list": { const spec = (matchSpec.test(value) && value.spec) || {}; const rangeValidate = (matchRange.test(value) && matchNumberWithRange(value.range).test) || (() => true); return defaultRequired( matches .arrayOf(guardAll(spec as any)) .validate((x) => rangeValidate(x.length), "valid length"), value, ) as any; } case "select": if (matchValues.test(value)) { const valueKeys = Object.keys(value.values); return defaultRequired( literals(valueKeys[0], ...valueKeys), value, ) as any; } return unknown as any; case "multiselect": if (matchValues.test(value)) { const maybeAddRangeValidate = , B>( x: X, ) => { if (!matchRange.test(value)) return x; return x.validate( (x) => matchNumberWithRange(value.range).test(x.length), "validLength", ); }; const valueKeys = Object.keys(value.values); return defaultRequired( maybeAddRangeValidate(arrayOf(literals(valueKeys[0], ...valueKeys))), value, ) as any; } return unknown as any; case "union": if (matchUnion.test(value)) { return some( ...Object.entries(value.variants) .filter(([name]) => string.test(name)) .map(([name, { spec }]) => object({ unionSelectKey: literals(name), unionValueKey: typeFromProps(spec), }), ), ) as any; } return unknown as any; } return unknown as any; } /** * InputSpec: Tells the UI how to ask for information, verification, and will send the service a config in a shape via the spec. * ValueSpecAny: This is any of the values in a config spec. * * Use this when we want to convert a config spec into a parser for what a config will look like * @param valueDictionary * @returns */ export function typeFromProps( valueDictionary: A, ): Parser> { if (!recordString.test(valueDictionary)) return unknown as any; return object( Object.fromEntries( Object.entries(valueDictionary).map(([key, value]) => [ key, guardAll(value), ]), ), ) as any; }