import * as matches from "ts-matches"; import { Parser, Validator } from "ts-matches"; import { Variants } from "../config/builder"; import { InputSpec, unionSelectKey, ValueSpec as ValueSpecAny } from "../config/config-types"; 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]: {unionSelectKey: key} & 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).map(([_, { spec }]) => 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; }