import { IDL } from "@icp-sdk/core/candid" import { Principal } from "@icp-sdk/core/principal" import type { BaseActor, FunctionName } from "@ic-reactor/core" import * as z from "zod" import { isQuery } from "../helpers.js" import { formatLabel } from "../arguments/helpers.js" import type { FormServiceMeta, FormArgumentsMeta, FormFieldNode, FormFieldType, FormRenderHint, VariableRefCandidate, } from "./types.js" import { cloneField, toFormValue } from "./helpers.js" export * from "./types.js" const COMPOUND_RENDER_HINT: FormRenderHint = { isCompound: true, isPrimitive: false, } const TEXT_RENDER_HINT: FormRenderHint = { isCompound: false, isPrimitive: true, inputType: "text", } const NUMBER_RENDER_HINT: FormRenderHint = { isCompound: false, isPrimitive: true, inputType: "number", } const CHECKBOX_RENDER_HINT: FormRenderHint = { isCompound: false, isPrimitive: true, inputType: "checkbox", } const FILE_RENDER_HINT: FormRenderHint = { isCompound: false, isPrimitive: true, inputType: "file", } /** * Visitor that generates form-oriented metadata from Candid IDL types. * * Each generated field includes: * - `schema` for validation * - `component` for renderer selection * - `renderHint` for UI behavior hints */ export class CandidFormVisitor extends IDL.Visitor< string, FormFieldNode | FormArgumentsMeta | FormServiceMeta > { private recCache = new Map, FormFieldNode>() private recursiveSchemas: Map = new Map() private nameStack: string[] = [] private withName(name: string, fn: () => T): T { this.nameStack.push(name) try { return fn() } finally { this.nameStack.pop() } } private currentName(): string { return this.nameStack.join("") } public visitService(t: IDL.ServiceClass): FormServiceMeta { const result = {} as FormServiceMeta for (const [functionName, func] of t._fields) { result[functionName as FunctionName] = func.accept( this, functionName ) as FormArgumentsMeta } return result } public visitFunc(t: IDL.FuncClass, functionName: string): FormArgumentsMeta { const functionType = isQuery(t) ? "query" : "update" const args = t.argTypes.map( (argType, index) => this.withName(`[${index}]`, () => argType.accept(this, `__arg${index}`) ) as FormFieldNode ) const argCount = args.length const schema = argCount === 0 ? (z.tuple([]) as unknown as z.ZodTuple< [z.ZodTypeAny, ...z.ZodTypeAny[]] >) : z.tuple( args.map((field) => field.schema) as [ z.ZodTypeAny, ...z.ZodTypeAny[], ] ) return { candidType: t.name, functionType, functionName, args, defaults: args.map((arg) => arg.defaultValue), argCount, isEmpty: argCount === 0, schema, } } public buildFunctionMeta( func: IDL.FuncClass, functionName: string ): FormArgumentsMeta { return func.accept(this, functionName) as FormArgumentsMeta } public buildValueMeta( valueType: IDL.Type, functionName = "__value" ): FormArgumentsMeta { const valueField = this.withName("[0]", () => valueType.accept(this, "__arg0") ) as FormFieldNode return { candidType: valueType.display?.() ?? valueType.name ?? "value", functionType: "value", functionName, args: [valueField], defaults: [valueField.defaultValue], argCount: 1, isEmpty: false, schema: z.tuple([valueField.schema]), } } public toFormValuesFromDecodedArgs( fields: FormFieldNode[], decodedArgs: unknown[] ): unknown[] { return fields.map((field, index) => toFormValue(field, decodedArgs[index])) } public collectRefCandidatesFromRoot( sourceNodeId: string, rootExpr: string, rootLabel: string, rootField: FormFieldNode ): VariableRefCandidate[] { const out: VariableRefCandidate[] = [] const walk = (field: FormFieldNode, expr: string, label: string) => { out.push({ expr, label, candidType: field.candidType, fieldType: field.type, sourceNodeId, }) switch (field.type) { case "record": case "tuple": for (const child of field.fields) { walk(child, `${expr}.${child.label}`, `${label}.${child.label}`) } break case "variant": for (const child of field.options) { walk(child, `${expr}.${child.label}`, `${label}.${child.label}`) } break case "optional": walk(field.innerField, `${expr}.some`, `${label}.some`) break case "vector": case "recursive": case "unknown": case "blob": case "principal": case "text": case "number": case "boolean": case "null": break } } walk(rootField, rootExpr, rootLabel) return out } public buildFieldForType( type: IDL.Type, label: string, path: string ): FormFieldNode { return this.withName(path, () => type.accept(this, label)) as FormFieldNode } public buildTupleFieldForTypes( types: IDL.Type[], label: string, path: string ): FormFieldNode { const tupleType = IDL.Tuple(...types) return this.withName(path, () => tupleType.accept(this, label) ) as FormFieldNode } public visitRecord( t: IDL.RecordClass, fields_: Array<[string, IDL.Type]>, label: string ): FormFieldNode { const name = this.currentName() const fields = fields_.map( ([key, childType]) => this.withName(name ? `.${key}` : key, () => childType.accept(this, key) ) as FormFieldNode ) const schema = z.object( Object.fromEntries(fields.map((field) => [field.label, field.schema])) ) return { type: "record", label, displayLabel: formatLabel(label), name, component: "record-container", renderHint: COMPOUND_RENDER_HINT, candidType: t.display?.() ?? t.name ?? "record", fields, defaultValue: Object.fromEntries( fields.map((f) => [f.label, f.defaultValue]) ), schema, } } public visitTuple( _t: IDL.TupleClass, components: IDL.Type[], label: string ): FormFieldNode { const name = this.currentName() const fields = components.map( (childType, index) => this.withName(`[${index}]`, () => childType.accept(this, String(index)) ) as FormFieldNode ) const schema = fields.length === 0 ? (z.tuple([]) as unknown as z.ZodTuple< [z.ZodTypeAny, ...z.ZodTypeAny[]] >) : z.tuple( fields.map((field) => field.schema) as [ z.ZodTypeAny, ...z.ZodTypeAny[], ] ) return { type: "tuple", label, displayLabel: formatLabel(label), name, component: "tuple-container", renderHint: COMPOUND_RENDER_HINT, candidType: "tuple", fields, defaultValue: fields.map((f) => f.defaultValue), schema, } } public visitVariant( t: IDL.VariantClass, fields_: Array<[string, IDL.Type]>, label: string ): FormFieldNode { const name = this.currentName() const options = fields_.map( ([key, childType]) => this.withName(`.${key}`, () => childType.accept(this, key) ) as FormFieldNode ) const first = options[0] ?? this.primitive("null", "null", `${name}.null`, "null", null, z.null()) const defaultOption = first.label const variantSchemas = options.map((option) => option.type === "null" ? z.object({ _type: z.literal(option.label) }) : z.object({ _type: z.literal(option.label), [option.label]: option.schema, }) ) const getOption = (option: string): FormFieldNode => { const found = options.find((o) => o.label === option) if (!found) { throw new Error(`Unknown variant option: ${option}`) } return found } const getOptionDefault = (option: string): Record => { const field = getOption(option) return field.type === "null" ? { _type: option } : { _type: option, [option]: field.defaultValue } } const getSelectedKey = (value: Record): string => { if (typeof value?._type === "string") return value._type const firstPresent = Object.keys(value ?? {}).find((k) => options.some((o) => o.label === k) ) return firstPresent ?? defaultOption } const getSelectedOption = (value: Record): FormFieldNode => getOption(getSelectedKey(value)) return { type: "variant", label, displayLabel: formatLabel(label), name, component: "variant-select", renderHint: COMPOUND_RENDER_HINT, candidType: t.display?.() ?? t.name ?? "variant", options, defaultOption, defaultValue: getOptionDefault(defaultOption), schema: variantSchemas.length === 0 ? z.object({ _type: z.literal(defaultOption) }) : z.union( variantSchemas as unknown as [z.ZodTypeAny, ...z.ZodTypeAny[]] ), getOptionDefault, getOption, getSelectedKey, getSelectedOption, } } public visitOpt( _t: IDL.OptClass, ty: IDL.Type, label: string ): FormFieldNode { const name = this.currentName() const innerField = ty.accept(this, label) as FormFieldNode return { type: "optional", label, displayLabel: formatLabel(label), name, component: "optional-toggle", renderHint: COMPOUND_RENDER_HINT, candidType: `opt ${innerField.candidType}`, innerField, defaultValue: null, schema: z.union([ innerField.schema, z.null(), z.undefined().transform(() => null), ]), getInnerDefault: () => innerField.defaultValue, isEnabled: (value: unknown) => value !== null && value !== undefined, } } public visitVec( _t: IDL.VecClass, ty: IDL.Type, label: string ): FormFieldNode { const name = this.currentName() if (ty instanceof IDL.FixedNatClass && ty._bits === 8) { return this.primitive( "blob", label, name, "blob", "", z.union([z.string(), z.array(z.number()), z.instanceof(Uint8Array)]) ) } const itemFieldTemplate = this.withName("[0]", () => ty.accept(this, `${label}_item`) ) as FormFieldNode const createItemField = (index: number, overrides?: { label?: string }) => { return this.withName(`[${index}]`, () => ty.accept(this, overrides?.label ?? String(index)) ) as FormFieldNode } return { type: "vector", label, displayLabel: formatLabel(label), name, component: "vector-list", renderHint: COMPOUND_RENDER_HINT, candidType: `vec ${ty.display?.() ?? ty.name}`, itemField: itemFieldTemplate, defaultValue: [], schema: z.array(itemFieldTemplate.schema), getItemDefault: () => cloneField(itemFieldTemplate).defaultValue, createItemField, } } public visitRec( t: IDL.RecClass, ty: IDL.ConstructType, label: string ): FormFieldNode { const name = this.currentName() const typeName = ty.name || "RecursiveType" let schema: z.ZodTypeAny if (this.recCache.has(t)) { return this.recCache.get(t)! } if (this.recursiveSchemas.has(typeName)) { schema = this.recursiveSchemas.get(typeName)! } else { schema = z.lazy(() => (ty.accept(this, label) as FormFieldNode).schema) this.recursiveSchemas.set(typeName, schema) } const node: FormFieldNode = { type: "recursive", label, displayLabel: formatLabel(label), name, component: "recursive-lazy", renderHint: COMPOUND_RENDER_HINT, candidType: ty.name, defaultValue: undefined, schema, typeName: ty.name, extract: () => this.withName(name, () => ty.accept(this, label)) as FormFieldNode, } this.recCache.set(t, node) return node } public visitPrincipal(_t: IDL.PrincipalClass, label: string): FormFieldNode { return this.primitive( "principal", label, this.currentName(), "principal", "", z.custom( (val) => { if (val instanceof Principal) return true if (typeof val === "string") { try { Principal.fromText(val) return true } catch { return false } } return false }, { message: "Invalid Principal format" } ) ) } public visitText(_t: IDL.TextClass, label: string): FormFieldNode { return this.primitive( "text", label, this.currentName(), "text", "", z.string().min(1, "Required") ) } public visitBool(_t: IDL.BoolClass, label: string): FormFieldNode { return this.primitive( "boolean", label, this.currentName(), "bool", false, z.boolean() ) } public visitNull(_t: IDL.NullClass, label: string): FormFieldNode { return this.primitive( "null", label, this.currentName(), "null", null, z.null() ) } public visitInt(_t: IDL.IntClass, label: string): FormFieldNode { return this.primitive( "number", label, this.currentName(), "int", "", z .string() .min(1, "Required") .regex(/^-?\d+$/, "Must be an integer") ) } public visitNat(_t: IDL.NatClass, label: string): FormFieldNode { return this.primitive( "number", label, this.currentName(), "nat", "", z.string().regex(/^\d+$/, "Must be a positive number") ) } public visitFloat(t: IDL.FloatClass, label: string): FormFieldNode { return this.primitive( "number", label, this.currentName(), `float${t._bits}`, "", z .string() .min(1, "Required") .refine((val) => !isNaN(Number(val)) && isFinite(Number(val)), { message: "Must be a valid number", }) ) } public visitFixedInt(t: IDL.FixedIntClass, label: string): FormFieldNode { return this.primitive( "number", label, this.currentName(), `int${t._bits}`, "", z .string() .min(1, "Required") .regex(/^-?\d+$/, "Must be an integer") ) } public visitFixedNat(t: IDL.FixedNatClass, label: string): FormFieldNode { return this.primitive( "number", label, this.currentName(), `nat${t._bits}`, "", z.string().regex(/^\d+$/, "Must be a positive number") ) } public visitType(t: IDL.Type, label: string): FormFieldNode { return this.primitive( "unknown", label, this.currentName(), t.name ?? "unknown", null, z.any() ) } private primitive( type: T, label: string, name: string, candidType: string, defaultValue: unknown, schema: z.ZodTypeAny ): Extract { const component = type === "blob" ? "blob-upload" : type === "principal" ? "principal-input" : type === "text" ? "text-input" : type === "number" ? "number-input" : type === "boolean" ? "boolean-checkbox" : type === "null" ? "null-hidden" : "unknown-fallback" const renderHint = type === "blob" ? FILE_RENDER_HINT : type === "number" ? NUMBER_RENDER_HINT : type === "boolean" ? CHECKBOX_RENDER_HINT : TEXT_RENDER_HINT return { type, label, displayLabel: formatLabel(label), name, component, renderHint, candidType, defaultValue, schema, } as Extract } }