UNPKG

prisma-zod-generator

Version:

Prisma 2+ generator to emit Zod schemas from your Prisma schema

1,390 lines 99.3 kB
"use strict"; /** * Zod Comment Parser * * Parses @zod validation annotations from Prisma schema field comments * to generate enhanced Zod validation schemas. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.extractFieldComments = extractFieldComments; exports.extractFieldComment = extractFieldComment; exports.normalizeComment = normalizeComment; exports.detectZodAnnotations = detectZodAnnotations; exports.validateCommentStructure = validateCommentStructure; exports.getExtractionStatistics = getExtractionStatistics; exports.filterZodComments = filterZodComments; exports.getAllExtractionErrors = getAllExtractionErrors; exports.parseZodAnnotations = parseZodAnnotations; exports.validateZodAnnotations = validateZodAnnotations; exports.mapAnnotationsToZodSchema = mapAnnotationsToZodSchema; exports.generateCompleteZodSchema = generateCompleteZodSchema; exports.getBaseZodType = getBaseZodType; exports.getBaseZodTypeForField = getBaseZodTypeForField; exports.getRequiredImports = getRequiredImports; exports.detectCustomImports = detectCustomImports; exports.parseCustomImports = parseCustomImports; exports.findBalancedParentheses = findBalancedParentheses; exports.extractModelCustomImports = extractModelCustomImports; exports.extractFieldCustomImports = extractFieldCustomImports; const logger_1 = require("../utils/logger"); /** * Extract comments from Prisma DMMF model fields * * This function processes Prisma model field definitions and extracts * comment data with proper normalization and context for downstream parsing. * * @param models - Array of Prisma DMMF models * @returns Array of extracted field comments with context */ function extractFieldComments(models) { const extractedComments = []; for (const model of models) { for (const field of model.fields) { const comment = field.documentation || ''; // Skip fields without comments if (!comment.trim()) { continue; } try { const context = { modelName: model.name, fieldName: field.name, fieldType: field.type, comment: comment, isOptional: !field.isRequired, isList: field.isList, }; const extractedComment = extractFieldComment(context); extractedComments.push(extractedComment); } catch (error) { // Add error handling for individual field processing failures extractedComments.push({ context: { modelName: model.name, fieldName: field.name, fieldType: field.type, comment: comment, isOptional: !field.isRequired, isList: field.isList, }, normalizedComment: '', hasZodAnnotations: false, extractionErrors: [ `Failed to extract comment: ${error instanceof Error ? error.message : String(error)}`, ], }); } } } return extractedComments; } /** * Extract and normalize a single field comment * * @param context - Field context with comment and metadata * @returns Extracted comment with processing information */ function extractFieldComment(context) { const errors = []; try { // Normalize whitespace and handle multi-line comments const normalizedComment = normalizeComment(context.comment); // Check if comment contains @zod annotations const hasZodAnnotations = detectZodAnnotations(normalizedComment); // Validate comment structure const structureErrors = validateCommentStructure(normalizedComment, context); errors.push(...structureErrors); return { context, normalizedComment, hasZodAnnotations, extractionErrors: errors, }; } catch (error) { errors.push(`Comment extraction failed: ${error instanceof Error ? error.message : String(error)}`); return { context, normalizedComment: '', hasZodAnnotations: false, extractionErrors: errors, }; } } /** * Normalize comment content for consistent processing * * Handles multi-line comments, whitespace normalization, and ensures * consistent format for downstream parsing. * * @param comment - Raw comment from Prisma field * @returns Normalized comment string */ function normalizeComment(comment) { if (!comment || typeof comment !== 'string') { return ''; } // Handle multi-line comments by preserving @zod annotations on separate lines const lines = comment.split(/\r?\n/).map((line) => line.trim()); // Remove empty lines and normalize whitespace const normalizedLines = lines .filter((line) => line.length > 0) .map((line) => line.replace(/\s+/g, ' ').trim()); return normalizedLines.join(' '); } /** * Detect if comment contains @zod validation annotations * * Performs a quick check to determine if the comment contains * any @zod annotations before more expensive parsing operations. * * @param comment - Normalized comment string * @returns True if @zod annotations are detected */ function detectZodAnnotations(comment) { if (!comment) { return false; } // Look for @zod patterns (case-insensitive) - allow optional whitespace between @zod and . const zodPattern = /@zod\s*\./i; return zodPattern.test(comment); } /** * Validate comment structure and detect potential issues * * Performs structural validation to catch common comment formatting * issues that could cause parsing problems later. * * @param comment - Normalized comment string * @param context - Field context for error reporting * @returns Array of validation errors */ function validateCommentStructure(comment, context) { const errors = []; if (!comment) { return errors; // Empty comments are valid } // Check for unmatched parentheses in @zod annotations if (detectZodAnnotations(comment)) { const parenthesesErrors = validateParentheses(comment, context); errors.push(...parenthesesErrors); // Check for malformed @zod syntax const syntaxErrors = validateZodSyntax(comment, context); errors.push(...syntaxErrors); } return errors; } /** * Validate parentheses matching in comments * * @param comment - Comment to validate * @param context - Field context for error reporting * @returns Array of parentheses validation errors */ function validateParentheses(comment, context) { const errors = []; let parenCount = 0; for (let i = 0; i < comment.length; i++) { const char = comment[i]; if (char === '(') { parenCount++; } else if (char === ')') { parenCount--; if (parenCount < 0) { errors.push(`${context.modelName}.${context.fieldName}: Unmatched closing parenthesis at position ${i}`); break; } } } if (parenCount > 0) { errors.push(`${context.modelName}.${context.fieldName}: ${parenCount} unmatched opening parenthesis(es)`); } return errors; } /** * Extract @zod annotations from comment with proper nested parentheses handling * * @param comment - Comment string * @returns Array of annotation strings */ function extractZodAnnotations(comment) { const annotations = []; const zodRegex = /@zod\s*\./gi; let match; while ((match = zodRegex.exec(comment)) !== null) { const startIndex = match.index; // Find the method name let currentIndex = startIndex + match[0].length; // Skip whitespace while (currentIndex < comment.length && /\s/.test(comment[currentIndex])) { currentIndex++; } // Extract method name const methodStart = currentIndex; while (currentIndex < comment.length && /[a-zA-Z0-9_]/.test(comment[currentIndex])) { currentIndex++; } if (currentIndex === methodStart) continue; // No method name found // Skip whitespace while (currentIndex < comment.length && /\s/.test(comment[currentIndex])) { currentIndex++; } // Optional generic args after method name (e.g., <"UserId">) if (currentIndex < comment.length && comment[currentIndex] === '<') { const genericRes = parseGenericArgs(comment, currentIndex); if (genericRes) { currentIndex = genericRes.nextIndex; // Skip whitespace after generics while (currentIndex < comment.length && /\s/.test(comment[currentIndex])) { currentIndex++; } } } // Check if there are parameters if (currentIndex < comment.length && comment[currentIndex] === '(') { // Find the matching closing parenthesis with proper nesting let depth = 0; let inString = false; let stringChar = ''; while (currentIndex < comment.length) { const char = comment[currentIndex]; if (!inString && (char === '"' || char === "'")) { inString = true; stringChar = char; } else if (inString && char === stringChar && comment[currentIndex - 1] !== '\\') { inString = false; stringChar = ''; } else if (!inString) { if (char === '(') { depth++; } else if (char === ')') { depth--; if (depth === 0) { currentIndex++; // Include the closing parenthesis break; } } } currentIndex++; } if (depth === 0) { annotations.push(comment.substring(startIndex, currentIndex)); } } else { // No parameters, just method name (and possible generics) annotations.push(comment.substring(startIndex, currentIndex)); } } return annotations; } /** * Parse @zod annotations with proper nested parentheses support * * @param comment - Comment string * @param context - Field context * @returns Parse result with annotations and errors */ function parseZodAnnotationsWithNestedParentheses(comment, context) { const result = { annotations: [], parseErrors: [], isValid: true, }; const annotationStrings = extractZodAnnotations(comment); for (const annotationStr of annotationStrings) { try { const methodMatch = annotationStr.match(/@zod\s*\.([a-zA-Z_][a-zA-Z0-9_]*)/i); if (!methodMatch) continue; const methodName = methodMatch[1]; // Scan after method token for optional generics before params const methodToken = methodMatch[0]; const methodTokenIndex = annotationStr.indexOf(methodToken); let scanIndex = methodTokenIndex + methodToken.length; while (scanIndex < annotationStr.length && /\s/.test(annotationStr[scanIndex])) scanIndex++; let genericArgs = ''; if (annotationStr[scanIndex] === '<') { const genericRes = parseGenericArgs(annotationStr, scanIndex); if (genericRes) { genericArgs = genericRes.segment; scanIndex = genericRes.nextIndex; while (scanIndex < annotationStr.length && /\s/.test(annotationStr[scanIndex])) scanIndex++; } } const paramStart = annotationStr.indexOf('(', scanIndex); if (paramStart !== -1) { const paramEnd = annotationStr.lastIndexOf(')'); if (paramEnd !== -1) { const parameterString = annotationStr.substring(paramStart, paramEnd + 1); const fakeMatch = [ annotationStr, methodName, parameterString, ]; fakeMatch.index = 0; const annotation = parseZodAnnotation(fakeMatch, context, genericArgs); result.annotations.push(annotation); } } else { const fakeMatch = [annotationStr, methodName, '']; fakeMatch.index = 0; const annotation = parseZodAnnotation(fakeMatch, context, genericArgs); result.annotations.push(annotation); } } catch (error) { const errorMessage = `Failed to parse @zod annotation "${annotationStr}": ${error instanceof Error ? error.message : String(error)}`; result.parseErrors.push(errorMessage); result.isValid = false; } } return result; } /** * Validate basic @zod annotation syntax * * @param comment - Comment to validate * @param context - Field context for error reporting * @returns Array of syntax validation errors */ function validateZodSyntax(comment, context) { const errors = []; // Look for @zod annotations and validate basic structure - allow optional whitespace const zodMatches = extractZodAnnotations(comment); if (zodMatches) { zodMatches.forEach((match, _index) => { // Check for common syntax issues if (match.includes('..')) { errors.push(`${context.modelName}.${context.fieldName}: Invalid double dots in @zod annotation: ${match}`); } if (match.includes('@zod.()')) { errors.push(`${context.modelName}.${context.fieldName}: Empty method name in @zod annotation: ${match}`); } }); } return errors; } /** * Get comment extraction statistics for debugging * * @param extractedComments - Array of extracted comments * @returns Statistics about comment extraction */ function getExtractionStatistics(extractedComments) { const totalFields = extractedComments.length; const fieldsWithComments = extractedComments.filter((ec) => ec.normalizedComment.length > 0).length; const fieldsWithZodAnnotations = extractedComments.filter((ec) => ec.hasZodAnnotations).length; const extractionErrors = extractedComments.filter((ec) => ec.extractionErrors.length > 0).length; return { totalFields, fieldsWithComments, fieldsWithZodAnnotations, extractionErrors, }; } /** * Filter extracted comments to only those with @zod annotations * * @param extractedComments - Array of all extracted comments * @returns Array of comments containing @zod annotations */ function filterZodComments(extractedComments) { return extractedComments.filter((ec) => ec.hasZodAnnotations && ec.extractionErrors.length === 0); } /** * Get all extraction errors across multiple field comments * * @param extractedComments - Array of extracted comments * @returns Array of all errors with field context */ function getAllExtractionErrors(extractedComments) { return extractedComments .filter((ec) => ec.extractionErrors.length > 0) .map((ec) => ({ modelName: ec.context.modelName, fieldName: ec.context.fieldName, errors: ec.extractionErrors, })); } /** * Parse @zod annotations from normalized comment text * * Extracts and parses all @zod annotations including method names, * parameters, and handles complex chaining scenarios. * * @param comment - Normalized comment string * @param context - Field context for error reporting * @returns Parse result with annotations and errors */ function parseZodAnnotations(comment, context) { const result = { annotations: [], parseErrors: [], isValid: true, }; if (!comment || !detectZodAnnotations(comment)) { return result; } try { // Check if we have multiple method calls (chained) by counting dots after @zod const methodCount = (comment.match(/\.([a-zA-Z_][a-zA-Z0-9_]*)/g) || []).length; const hasChainedAnnotations = methodCount > 1; if (hasChainedAnnotations) { // Handle chained @zod annotations (e.g., @zod.min(1).max(100)) const chainedAnnotations = parseChainedZodAnnotations(comment, context); result.annotations.push(...chainedAnnotations.annotations); result.parseErrors.push(...chainedAnnotations.parseErrors); if (chainedAnnotations.parseErrors.length > 0) { result.isValid = false; } } else { // Handle simple @zod annotations with proper nested parentheses support const annotations = parseZodAnnotationsWithNestedParentheses(comment, context); result.annotations.push(...annotations.annotations); result.parseErrors.push(...annotations.parseErrors); if (annotations.parseErrors.length > 0) { result.isValid = false; } } // Drop bare base-type tokens (e.g. the leading `.string()` in // `@zod.string().min(1)`): they are no-ops in the emitted chain, and // passing them through validation rejects the whole comment (issue #374). // 'date' is intentionally absent: @zod.date() is a registered format method. const BASE_TYPE_TOKENS = new Set(['string', 'number', 'boolean', 'bigint']); result.annotations = result.annotations.filter((a) => !(BASE_TYPE_TOKENS.has(a.method) && (!a.parameters || a.parameters.length === 0))); // Validate and filter the parsed annotations if (result.annotations.length > 0) { const validAnnotations = []; const validationErrors = []; for (const annotation of result.annotations) { try { validateZodMethod(annotation, context); validAnnotations.push(annotation); } catch (error) { validationErrors.push(`${context.modelName}.${context.fieldName}: ${error instanceof Error ? error.message : String(error)}`); // Continue processing other annotations instead of failing completely } } // Only fail if no annotations are valid if (validAnnotations.length === 0 && validationErrors.length > 0) { result.parseErrors.push(...validationErrors); result.isValid = false; } else { result.annotations = validAnnotations; // Log validation errors but don't fail if we have some valid annotations if (validationErrors.length > 0) { logger_1.logger.warn('Some @zod annotations were invalid and filtered out:', validationErrors); } } } } catch (error) { result.parseErrors.push(`General parsing error: ${error instanceof Error ? error.message : String(error)}`); result.isValid = false; } return result; } /** * Parse a single @zod annotation match * * @param match - Regex match result * @param context - Field context for error reporting * @returns Parsed annotation object */ function parseZodAnnotation(match, context, genericArgs = '') { const [fullMatch, methodName, parameterString] = match; const position = match.index || 0; // Parse parameters if present let parameters = []; if (parameterString && parameterString.trim() !== '()') { try { parameters = parseZodParameters(parameterString, context, methodName); } catch (error) { throw new Error(`Invalid parameters in ${methodName}: ${error instanceof Error ? error.message : String(error)}`); } } return { method: methodName, parameters, rawMatch: fullMatch, position, genericArgs: genericArgs || undefined, }; } /** * Parse chained @zod annotations like @zod.min(1).max(100) * * @param comment - Comment string * @param context - Field context * @returns Parse result for chained annotations */ function parseChainedZodAnnotations(comment, context) { const result = { annotations: [], parseErrors: [], isValid: true, }; try { // Find the @zod annotation start const zodIndex = comment.indexOf('@zod'); if (zodIndex === -1) { return result; } // Extract the entire @zod chain starting from @zod const zodChain = comment.slice(zodIndex); // Use the improved parseMethodChain function to handle the entire chain const chainedMethods = parseMethodChain(zodChain, context); result.annotations.push(...chainedMethods); } catch (error) { result.parseErrors.push(`Failed to parse chained annotation: ${error instanceof Error ? error.message : String(error)}`); result.isValid = false; } return result; } /** * Parse a method chain like .min(1).max(100) * * @param chainString - The chained method string * @param context - Field context * @returns Array of parsed annotations */ function parseMethodChain(chainString, context) { const annotations = []; // Parse method calls manually to handle nested parentheses properly let i = 0; while (i < chainString.length) { // Find next method call starting with a dot const dotIndex = chainString.indexOf('.', i); if (dotIndex === -1) break; // Extract method name const methodStart = dotIndex + 1; let methodEnd = methodStart; while (methodEnd < chainString.length && /[a-zA-Z0-9_]/.test(chainString[methodEnd])) { methodEnd++; } if (methodEnd === methodStart) { i = dotIndex + 1; continue; } const methodName = chainString.slice(methodStart, methodEnd); // Skip whitespace while (methodEnd < chainString.length && /\s/.test(chainString[methodEnd])) { methodEnd++; } // Optional generic arguments after method name let iAfterName = methodEnd; while (iAfterName < chainString.length && /\s/.test(chainString[iAfterName])) { iAfterName++; } let genericArgs = ''; if (iAfterName < chainString.length && chainString[iAfterName] === '<') { const genRes = parseGenericArgs(chainString, iAfterName); if (genRes) { genericArgs = genRes.segment; iAfterName = genRes.nextIndex; while (iAfterName < chainString.length && /\s/.test(chainString[iAfterName])) { iAfterName++; } } } // Check for parameter list let parameterString = ''; if (iAfterName < chainString.length && chainString[iAfterName] === '(') { // Find matching closing parenthesis, handling nested parentheses let parenCount = 1; const paramStart = iAfterName + 1; let paramEnd = paramStart; while (paramEnd < chainString.length && parenCount > 0) { const char = chainString[paramEnd]; if (char === '(') { parenCount++; } else if (char === ')') { parenCount--; } paramEnd++; } if (parenCount === 0) { parameterString = chainString.slice(iAfterName, paramEnd); i = paramEnd; } else { throw new Error(`Unmatched parentheses in method ${methodName}`); } } else { // No parameters parameterString = '()'; i = iAfterName; } // Parse parameters let parameters = []; if (parameterString && parameterString.trim() !== '()') { try { parameters = parseZodParameters(parameterString, context, methodName); } catch (error) { throw new Error(`Invalid parameters in chained method ${methodName}: ${error instanceof Error ? error.message : String(error)}`); } } annotations.push({ method: methodName, parameters, rawMatch: `.${methodName}${genericArgs}${parameterString}`, position: dotIndex, genericArgs: genericArgs || undefined, }); } return annotations; } /** * Parse parameter string from @zod annotation * * Handles various parameter types: numbers, strings, booleans, arrays, objects * * @param parameterString - Parameter string including parentheses * @param context - Field context for error reporting * @param methodName - Method name for error context * @returns Array of parsed parameters */ function parseZodParameters(parameterString, context, methodName) { // Remove outer parentheses const innerParams = parameterString.slice(1, -1).trim(); if (!innerParams) { return []; } try { // Handle simple cases first if (isSimpleParameter(innerParams)) { return [parseSimpleParameter(innerParams)]; } // Handle multiple parameters - split by commas not inside quotes or nested structures const paramParts = splitParameters(innerParams); return paramParts.map((part) => parseSimpleParameter(part.trim())); } catch (error) { throw new Error(`Failed to parse parameters "${innerParams}" for ${methodName}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Check if parameter string is a simple single parameter * * @param paramString - Parameter string * @returns True if simple parameter */ function isSimpleParameter(paramString) { const trimmed = paramString.trim(); // Check if it contains unquoted commas (indicating multiple parameters) let inQuotes = false; let quoteChar = ''; let bracketDepth = 0; for (let i = 0; i < trimmed.length; i++) { const char = trimmed[i]; if (!inQuotes && (char === '"' || char === "'")) { inQuotes = true; quoteChar = char; } else if (inQuotes && char === quoteChar && trimmed[i - 1] !== '\\') { inQuotes = false; quoteChar = ''; } else if (!inQuotes) { if (char === '[' || char === '{' || char === '(') { bracketDepth++; } else if (char === ']' || char === '}' || char === ')') { bracketDepth--; } else if (char === ',' && bracketDepth === 0) { return false; // Multiple parameters } } } return true; } /** * Parse generic arguments starting at the given index (which must point to '<'). * Handles nested angle brackets and quoted strings. * Returns the captured segment and the index immediately after it. */ function parseGenericArgs(source, startIndex) { if (source[startIndex] !== '<') return null; let i = startIndex; let depth = 0; let inString = false; let stringChar = ''; while (i < source.length) { const char = source[i]; if (!inString && (char === '"' || char === "'")) { inString = true; stringChar = char; } else if (inString && char === stringChar && source[i - 1] !== '\\') { inString = false; stringChar = ''; } else if (!inString) { if (char === '<') { depth++; } else if (char === '>') { depth--; if (depth === 0) { const segment = source.slice(startIndex, i + 1); return { segment, nextIndex: i + 1 }; } } } i++; } return null; } /** * Split parameter string by commas, respecting quotes and nested structures * * @param paramString - Parameter string * @returns Array of parameter parts */ function splitParameters(paramString) { const parts = []; let current = ''; let inQuotes = false; let quoteChar = ''; let bracketDepth = 0; for (let i = 0; i < paramString.length; i++) { const char = paramString[i]; if (!inQuotes && (char === '"' || char === "'")) { inQuotes = true; quoteChar = char; current += char; } else if (inQuotes && char === quoteChar && paramString[i - 1] !== '\\') { inQuotes = false; quoteChar = ''; current += char; } else if (!inQuotes) { if (char === '[' || char === '{' || char === '(') { bracketDepth++; current += char; } else if (char === ']' || char === '}' || char === ')') { bracketDepth--; current += char; } else if (char === ',' && bracketDepth === 0) { parts.push(current); current = ''; } else { current += char; } } else { current += char; } } if (current) { parts.push(current); } return parts; } /** * Parse a single parameter value * * @param paramValue - Parameter string value * @returns Parsed parameter value */ function parseSimpleParameter(paramValue) { const trimmed = paramValue.trim(); if (!trimmed) { throw new Error('Empty parameter value'); } // Boolean values if (trimmed === 'true') return true; if (trimmed === 'false') return false; // Null and undefined if (trimmed === 'null') return null; if (trimmed === 'undefined') return undefined; // Numbers (integers and floats) if (/^-?\d+$/.test(trimmed)) { return parseInt(trimmed, 10); } if (/^-?\d*\.\d+$/.test(trimmed)) { return parseFloat(trimmed); } // Quoted strings if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return trimmed.slice(1, -1); } // Regular expressions if (trimmed.startsWith('/') && trimmed.match(/\/[gimuy]*$/)) { try { const lastSlash = trimmed.lastIndexOf('/'); const pattern = trimmed.slice(1, lastSlash); const flags = trimmed.slice(lastSlash + 1); return new RegExp(pattern, flags); } catch { throw new Error(`Invalid regex: ${trimmed}`); } } // Arrays (basic support) if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { return JSON.parse(trimmed); } catch { throw new Error(`Invalid array: ${trimmed}`); } } // Objects (JavaScript object literal support) if (trimmed.startsWith('{') && trimmed.endsWith('}')) { try { // First try standard JSON parsing for simple cases return JSON.parse(trimmed); } catch { // For complex JavaScript object literals, return as-is as a special marker // This will be handled specially in formatParameters to preserve JavaScript expressions return { __js_object_literal__: trimmed }; } } // Complex expressions (like new Date(), function calls, etc.) if (trimmed.includes('(') && trimmed.includes(')')) { // Pass through complex expressions as-is for evaluation at runtime return trimmed; } // Unquoted strings (identifiers, etc.) if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { return trimmed; } // If we can't parse it, pass it through as a raw string // This allows complex expressions to be used in the generated code return trimmed; } /** * Validate parsed @zod annotations for semantic correctness * * @param annotations - Array of parsed annotations * @param context - Field context * @returns Array of validation errors */ function validateZodAnnotations(annotations, context) { const errors = []; for (const annotation of annotations) { try { validateZodMethod(annotation, context); } catch (error) { errors.push(`${context.modelName}.${context.fieldName}: ${error instanceof Error ? error.message : String(error)}`); } } return errors; } /** * Validate a single @zod method annotation * * @param annotation - Parsed annotation * @param context - Field context */ function validateZodMethod(annotation, context) { const { method, parameters } = annotation; // Common string validation methods const stringMethods = [ 'min', 'max', 'length', 'email', 'url', 'uuid', 'regex', 'includes', 'startsWith', 'endsWith', 'trim', 'toLowerCase', 'toUpperCase', 'datetime', 'ip', 'cidr', 'date', 'time', 'duration', 'normalize', 'uppercase', 'lowercase', // New Zod v4 string format methods - Issue #233 'httpUrl', 'hostname', 'guid', 'nanoid', 'cuid', 'cuid2', 'ulid', 'base64', 'base64url', 'hex', 'jwt', 'hash', 'ipv4', 'ipv6', 'cidrv4', 'cidrv6', 'emoji', 'isoDate', 'isoTime', 'isoDatetime', 'isoDuration', ]; // Common number validation methods const numberMethods = [ 'min', 'max', 'int', 'positive', 'negative', 'nonnegative', 'nonpositive', 'finite', 'multipleOf', 'step', 'gt', 'gte', 'lt', 'lte', ]; // Common array validation methods const arrayMethods = ['min', 'max', 'length', 'nonempty']; // Methods that require parameters const requiresParams = [ 'min', 'max', 'length', 'regex', 'includes', 'startsWith', 'endsWith', 'enum', 'default', 'refine', 'transform', 'multipleOf', 'step', 'gt', 'gte', 'lt', 'lte', 'catch', 'pipe', // New methods that require parameters - Issue #233 'hash', // hash requires algorithm parameter like "sha256" 'custom', // custom requires object/array schema parameter ]; // Methods that don't allow parameters const noParams = [ 'nonempty', 'trim', 'toLowerCase', 'toUpperCase', 'uppercase', 'lowercase', 'brand', 'readonly', ]; // Methods that accept optional parameters (format validation methods) const optionalParams = [ // Existing string format methods 'email', 'url', 'uuid', 'datetime', 'ip', 'cidr', 'date', 'time', 'duration', 'normalize', // New Zod v4 string format methods - Issue #233 'httpUrl', 'hostname', 'guid', 'nanoid', 'cuid', 'cuid2', 'ulid', 'base64', 'base64url', 'hex', 'jwt', 'ipv4', 'ipv6', 'cidrv4', 'cidrv6', 'emoji', 'isoDate', 'isoTime', 'isoDatetime', 'isoDuration', ]; // Methods that accept optional error message parameter const optionalErrorMessage = [ 'int', 'positive', 'negative', 'nonnegative', 'nonpositive', 'finite', ]; // Additional known methods not covered above const additionalMethods = [ 'default', 'optional', 'nullable', 'nullish', 'refine', 'transform', 'enum', 'object', 'array', 'record', 'json', 'catch', 'pipe', 'brand', 'readonly', // Schema metadata (issue #225/#371): describe works on v3 and v4; // meta is v4-only and is version-mapped during emission. 'describe', 'meta', ]; // Check if method is known const allKnownMethods = [ ...new Set([ ...stringMethods, ...numberMethods, ...arrayMethods, ...additionalMethods, ...optionalErrorMessage, ...optionalParams, ...requiresParams, // Add methods that require parameters ]), ]; if (!allKnownMethods.includes(method)) { throw new Error(`Unknown @zod method: ${method} - this method is not supported`); } // Validate parameter requirements if (requiresParams.includes(method) && parameters.length === 0) { throw new Error(`Method ${method} requires parameters`); } if (noParams.includes(method) && parameters.length > 0) { throw new Error(`Method ${method} does not accept parameters`); } // Validate optional parameter methods (string format methods) if (optionalParams.includes(method) && parameters.length > 1) { throw new Error(`Method ${method} accepts at most one parameter`); } // Validate optional error message methods if (optionalErrorMessage.includes(method) && parameters.length > 1) { throw new Error(`Method ${method} accepts at most one parameter (error message)`); } if (optionalErrorMessage.includes(method) && parameters.length === 1 && !isErrorMessageParam(parameters[0])) { throw new Error(`Method ${method} error message parameter must be a string or params object`); } // Type-specific validations based on field type if (context.fieldType === 'String') { validateStringMethodParameters(method, parameters); } else if (context.fieldType === 'Int' || context.fieldType === 'Float') { validateNumberMethodParameters(method, parameters); } } /** * Accept both error-message shapes Zod supports: the string shorthand and the * params object form (`{ message: "..." }` / `{ error: "..." }`). Object-form * arguments arrive either JSON-parsed or as `{ __js_object_literal__ }` markers. */ function isErrorMessageParam(param) { return (typeof param === 'string' || (typeof param === 'object' && param !== null && !Array.isArray(param) && !(param instanceof RegExp))); } /** * Validate parameters for string validation methods * * @param method - Validation method name * @param parameters - Method parameters */ function validateStringMethodParameters(method, parameters) { switch (method) { case 'min': case 'max': case 'length': // Allow 1 parameter (number) or 2 parameters (number + error message) if (parameters.length < 1 || parameters.length > 2 || typeof parameters[0] !== 'number' || parameters[0] < 0 || (parameters.length === 2 && !isErrorMessageParam(parameters[1]))) { throw new Error(`${method} requires a non-negative number parameter and optional error message (string or params object)`); } break; case 'regex': if (parameters.length < 1 || parameters.length > 2) { throw new Error(`regex requires 1-2 parameters (RegExp/string, optional error message)`); } if (!(parameters[0] instanceof RegExp || typeof parameters[0] === 'string')) { throw new Error(`regex first parameter must be a RegExp or string`); } if (parameters.length === 2 && typeof parameters[1] !== 'string') { throw new Error(`regex second parameter (error message) must be a string`); } break; case 'includes': case 'startsWith': case 'endsWith': if (parameters.length !== 1 || typeof parameters[0] !== 'string') { throw new Error(`${method} requires a string parameter`); } break; case 'hash': if (parameters.length !== 1 || typeof parameters[0] !== 'string') { throw new Error(`hash requires a single string algorithm parameter (e.g., 'sha256')`); } break; } } /** * Validate parameters for number validation methods * * @param method - Validation method name * @param parameters - Method parameters */ function validateNumberMethodParameters(method, parameters) { switch (method) { case 'min': case 'max': // Allow 1 parameter (number) or 2 parameters (number + error message) if (parameters.length < 1 || parameters.length > 2 || typeof parameters[0] !== 'number' || (parameters.length === 2 && !isErrorMessageParam(parameters[1]))) { throw new Error(`${method} requires a number parameter and optional error message`); } break; case 'positive': case 'negative': case 'int': case 'finite': // Allow 0 parameters or 1 parameter (error message) if (parameters.length > 1 || (parameters.length === 1 && !isErrorMessageParam(parameters[0]))) { throw new Error(`${method} accepts optional error message parameter`); } break; case 'multipleOf': case 'step': // Allow 1 parameter (number) or 2 parameters (number + error message) if (parameters.length < 1 || parameters.length > 2 || typeof parameters[0] !== 'number' || (parameters.length === 2 && !isErrorMessageParam(parameters[1]))) { throw new Error(`${method} requires a number parameter and optional error message`); } break; } } /** * Map parsed @zod annotations to Zod schema method calls * * Converts parsed annotations into actual Zod validation method chains * that can be used in generated schema code. * * @param annotations - Array of parsed annotations * @param context - Field context for validation * @param zodVersion - Zod version target ('v3', 'v4', or 'auto') * @returns Schema generation result with method chain and imports */ function mapAnnotationsToZodSchema(annotations, context, zodVersion = 'auto') { const result = { schemaChain: '', imports: new Set(), errors: [], isValid: true, }; if (annotations.length === 0) { return result; } try { const methodCalls = []; const methodResults = []; for (const annotation of annotations) { try { const methodResult = mapAnnotationToZodMethod(annotation, context, zodVersion); // Skip empty fallbacks (e.g., v3 for v4-only base methods) if (methodResult.methodCall) { methodCalls.push(methodResult.methodCall); } methodResults.push(methodResult); // Add any required imports if (methodResult.requiredImport) { result.imports.add(methodResult.requiredImport); } } catch (error) { result.errors.push(`Failed to map ${annotation.method}: ${error instanceof Error ? error.message : String(error)}`); result.isValid = false; } } // Join all method calls into a chain if (methodCalls.length > 0) { // Check if we have a base replacement method (like z.email() in v4) const baseReplacements = methodResults.filter((result) => result.isBaseReplacement); const regularReplacements = methodCalls.filter((call) => { var _a; return call && !call.startsWith('.') && !((_a = methodResults.find((r) => r.methodCall === call)) === null || _a === void 0 ? void 0 : _a.isBaseReplacement); }); const chainMethods = methodCalls.filter((call) => call.startsWith('.')); if (baseReplacements.length > 0) { // Handle base replacement (like z.email() in v4) with chaining if (baseReplacements.length > 1) { result.errors.push('Multiple base replacement methods detected - only one allowed per field'); result.isValid = false; } else { result.schemaChain = baseReplacements[0].methodCall + chainMethods.join(''); } } else if (regularReplacements.length > 0) { // Handle regular replacement methods (json, enum) const chainMethods = methodCalls.filter((call) => call.startsWith('.')); if (regularReplacements.length > 1) { result.errors.push('Multiple replacement methods detected - only one allowed per field'); result.isValid = false; } else { // Use the replacement method as base, then chain any additional methods result.schemaChain = regularReplacements[0] + chainMethods.join(''); } } else { // All are chaining methods, join normally result.schemaChain = methodCalls.join(''); } } } catch (error) { result.errors.push(`Schema generation failed: ${error instanceof Error ? error.message : String(error)}`); result.isValid = false; } return result; } /** * Resolve the target Zod version for syntax generation * * @param zodVersion - Version specification ('auto', 'v3', or 'v4') * @returns Resolved version ('v3' or 'v4') */ function resolveZodVersion(zodVersion) { var _a; if (zodVersion === 'v3' || zodVersion === 'v4') { return zodVersion; } // Auto-detect Zod version try { // Try to detect Zod version by loading zod package.json // eslint-disable-next-line @typescript-eslint/no-require-imports const zodPackage = require('zod/package.json'); const version = zodPackage.version; if (version) { const majorVersion = parseInt(version.split('.')[0], 10); return majorVersion >= 4 ? 'v4' : 'v3'; } } catch { // If we can't load zod package.json, try alternative detection try { // Try to detect by checking if z.email() method exists as standalone // eslint-disable-next-line @typescript-eslint/no-require-imports const zod = require('zod'); if (typeof ((_a = zod.z) === null || _a === void 0 ? void 0 : _a.email) === 'function') { return 'v4'; } } catch { // Ignore inner detection errors } } // Fallback to v3 if detection fails logger_1.logger.warn('Failed to detect Zod version, defaulting to v3 syntax'); return 'v3'; } /** * Map a single annotation to a Zod method call * * @param annotation - Parsed annotation * @param context - Field context * @param zodVersion - Zod version target for version-specific handling * @returns Method mapping result */ /** * Map .meta({...}) parameters to a .describe(...) call for Zod v3, which has * no .meta(). Returns null when no description key can be extracted. */ function metaParamsToDescribeCall(parameters) { const p = parameters[0]; if (!p || typeof p !== 'object') return null; if ('__js_object_literal__' in p) { const literal = p.__js_object_literal__; const m = literal.match(/\bdescription\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/); return m ? `.describe(${m[1]})` : null; } const description = p.description; return typeof description === 'string' ? `.describe(${JSON.stringify(description)})` : null; } function mapAnnotationToZodMethod(annotation, context, zodVersion = 'auto') { var _a; const { method, parameters } = annotation; // Schema metadata (issues #225/#371). describe exists on v3 and v4; meta is // v4-only and downgrades to .describe(description) on v3. if (method === 'describe') { return { methodCall: `.describe(${formatParameters(parameters)})` }; } if (method === 'meta') { if (resolveZodVersion(zodVersion) === 'v4') { return { methodCall: `.meta(${formatParameters(parameters)})` }; } const describeCall = metaParamsToDescribeCall(parameters); if (describeCall) { return { methodCall: describeCall }; } logger_1.logger.warn(`@zod.meta() requires Zod