import { z, ZodTypeAny } from 'zod' import * as path from 'path' import { accessSync, constants, lstatSync, existsSync } from 'fs' import { isString } from '@polymech/' import { logger } from '../logger' import { sync as exists } from '@polymech/fs/exists' import { sync as read } from '@polymech/fs/read' import { DEFAULT_VARS, resolve, resolveVariables } from '../variables' import { getDescription } from '../' import { isFile } from '../lib/fs' type TResult = { resolved: string, source: string, value: unknown } type TRefine = (src: string, ctx: any, variables: Record) => string | z.ZodNever type TTransform = (src: string, variables?: Record) => string | TResult type TExtend = { refine: Array, transform: Array } const DefaultPathSchemaBase = z.string().describe('Path to a file or directory') const PathErrorMessages = { INVALID_INPUT: 'INVALID_INPUT: ${inputPath}', PATH_DOES_NOT_EXIST: 'Path does not exist ${inputPath} = ${resolvedPath}', DIRECTORY_NOT_WRITABLE: 'Directory is not writable ${inputPath} = ${resolvedPath}', NOT_A_DIRECTORY: 'Path is not a directory or does not exist ${inputPath} = ${resolvedPath}', NOT_A_JSON_FILE: 'File is not a JSON file or does not exist ${inputPath} = ${resolvedPath}', PATH_NOT_ABSOLUTE: 'Path is not absolute ${inputPath} = ${resolvedPath}', PATH_NOT_RELATIVE: 'Path is not relative ${inputPath} = ${resolvedPath}', } as const export enum E_PATH { ENSURE_PATH_EXISTS = 1, INVALID_INPUT, ENSURE_DIRECTORY_WRITABLE, ENSURE_FILE_IS_JSON, ENSURE_PATH_IS_ABSOLUTE, ENSURE_PATH_IS_RELATIVE, GET_PATH_INFO } export const Transformers = { resolve: (val: string, variables: Record = {}) => { if (!val) { return null } return { resolved: path.resolve(resolve(val, false, variables)), source: val } }, json: (val: string | { resolved: string, source: string }, variables: Record = {}) => { if (!val) { return null } const resolved = path.resolve(resolve(isString(val) ? val : val.source, false, variables)) return { resolved, source: val, value: read(resolved, 'json') } }, string: (val: string | { resolved: string, source: string }, variables: Record = {}) => { if (!val) { return null } let src = isString(val) ? val : val.source src = resolve(src, false, variables) const resolved = path.resolve(src) if (!exists(resolved) || !isFile(resolved)) { return { resolved, source: val, value: null } } else { let value = null try { value = read(resolved, 'string') } catch (e) { logger.error('Failed to read file', { resolved, source: val, error: e.message }) } return { resolved, source: val, value } } } } export const TransformersDescription = [ { description: 'RESOLVE_PATH', fn: Transformers.resolve }, { description: 'READ_JSON', fn: Transformers.json }, { description: 'READ_STRING', fn: Transformers.string } ] const extendType = (type: ZodTypeAny, extend: TExtend, variables: Record = {}) => { if (Array.isArray(extend.refine)) { for (const refine of extend.refine) { type = type.refine(refine as any) } } else { type = type.refine(extend.refine) } if (Array.isArray(extend.transform)) { for (const transform of extend.transform) { type = type.transform((val) => transform(val, variables)) } } else { type = type.transform(extend.transform) } return type } const extendTypeDescription = (type: ZodTypeAny, extension: TExtend, variables: Record = {}) => { const description = getDescription(type) || '' let transformerDescriptions = 'Transformers:\n' if (Array.isArray(extension.transform)) { for (const transform of extension.transform) { transformerDescriptions += transformerDescription(transform) + '\n' } } else { transformerDescriptions += transformerDescription(extension.transform) + '\n' } type = type.describe(description + '\n' + transformerDescriptions) return type } const transformerDescription = (fn: TTransform) => { const description = TransformersDescription.find((t) => t.fn === fn) return description ? description.description : 'Unknown' } export const extendSchema = (baseSchema: z.ZodObject, extend: Record) => { const baseShape = baseSchema.shape const extendedShape: Record = { ...baseShape } for (const [key, refines] of Object.entries(extend)) { if (!baseShape[key]) continue let fieldSchema = baseShape[key] if (Array.isArray(refines.refine)) { for (const refine of refines.refine) { fieldSchema = fieldSchema.superRefine(refine) } } else { fieldSchema = fieldSchema.superRefine(refines) } if (Array.isArray(refines.transform)) { for (const transform of refines.transform) { fieldSchema = fieldSchema.transform((val) => transform(val)) } } else { fieldSchema = fieldSchema.transform(refines.transform) } extendedShape[key] = fieldSchema } return z.object(extendedShape) } export const ENSURE_DIRECTORY_WRITABLE = (inputPath: string, ctx: any, variables: Record) => { const resolvedPath = path.resolve(resolve(inputPath, false, variables)) const parts = path.parse(resolvedPath) if (resolvedPath && existsSync(parts.dir) && lstatSync(parts.dir).isDirectory()) { try { accessSync(resolvedPath, constants.W_OK) return resolvedPath } catch (e) { ctx.addIssue({ code: E_PATH.ENSURE_DIRECTORY_WRITABLE, message: resolveVariables(PathErrorMessages.DIRECTORY_NOT_WRITABLE, false, { inputPath, resolvedPath }) }) return z.NEVER } } else { ctx.addIssue({ code: E_PATH.ENSURE_DIRECTORY_WRITABLE, message: resolveVariables(PathErrorMessages.NOT_A_DIRECTORY, false, { inputPath, resolvedPath }) }) return z.NEVER } } export const IS_VALID_STRING = (inputPath: string) => { return isString(inputPath) } export const ENSURE_PATH_EXISTS = (inputPath: string, ctx: any, variables: Record) => { if (!inputPath || !ctx) { return z.NEVER } if (!isString(inputPath)) { ctx.addIssue({ code: E_PATH.INVALID_INPUT, message: resolveVariables(PathErrorMessages.INVALID_INPUT, false, {}) }) return z.NEVER } const resolvedPath = path.resolve(resolve(inputPath, false, variables)) if (!exists(resolvedPath)) { ctx.addIssue({ code: E_PATH.ENSURE_PATH_EXISTS, message: resolveVariables(PathErrorMessages.PATH_DOES_NOT_EXIST, false, { inputPath, resolvedPath }) }) return z.NEVER } return resolvedPath } export const test = () => { const BaseCompilerOptions = () => z.object({ root: DefaultPathSchemaBase.default(`${process.cwd()}`) }) const ret = extendSchema(BaseCompilerOptions(), { root: { refine: [ (val, ctx) => ENSURE_DIRECTORY_WRITABLE(val, ctx, DEFAULT_VARS({ exampleVar: 'exampleValue' })), (val, ctx) => ENSURE_PATH_EXISTS(val, ctx, DEFAULT_VARS({ exampleVar: 'exampleValue' })) ], transform: [ (val) => path.resolve(resolve(val, false, DEFAULT_VARS({ exampleVar: 'exampleValue' }))) ] } }) return ret } export const Templates = { json: { refine: [IS_VALID_STRING, ENSURE_PATH_EXISTS], transform: [Transformers.resolve, Transformers.json] }, string: { refine: [ENSURE_PATH_EXISTS], transform: [Transformers.resolve, Transformers.string] } } export const extend = (baseSchema: ZodTypeAny, template: any, variables: Record = {}) => { const type = extendType(baseSchema, template, variables) return extendTypeDescription(type, template, variables) }