5
5
import p5 from '../main.js' ;
6
6
import * as constants from '../constants.js' ;
7
7
import { z } from 'zod' ;
8
- import { fromError } from 'zod-validation-error' ;
9
8
import dataDoc from '../../../docs/parameterData.json' ;
10
9
11
10
function validateParams ( p5 , fn ) {
@@ -20,7 +19,7 @@ function validateParams(p5, fn) {
20
19
// and so on.
21
20
const p5Constructors = { } ;
22
21
23
- fn . _loadP5Constructors = function ( ) {
22
+ fn . loadP5Constructors = function ( ) {
24
23
// Make a list of all p5 classes to be used for argument validation
25
24
// This must be done only when everything has loaded otherwise we get
26
25
// an empty array
@@ -84,6 +83,17 @@ function validateParams(p5, fn) {
84
83
// Add web API schemas to the schema map.
85
84
Object . assign ( schemaMap , webAPISchemas ) ;
86
85
86
+ // For mapping 0-indexed parameters to their ordinal representation, e.g.
87
+ // "first" for 0, "second" for 1, "third" for 2, etc.
88
+ const ordinals = [ "first" , "second" , "third" , "fourth" , "fifth" , "sixth" , "seventh" , "eighth" , "ninth" , "tenth" ] ;
89
+
90
+ function extractFuncNameAndClass ( func ) {
91
+ const ichDot = func . lastIndexOf ( '.' ) ;
92
+ const funcName = func . slice ( ichDot + 1 ) ;
93
+ const funcClass = func . slice ( 0 , ichDot !== - 1 ? ichDot : 0 ) || 'p5' ;
94
+ return { funcName, funcClass } ;
95
+ }
96
+
87
97
/**
88
98
* This is a helper function that generates Zod schemas for a function based on
89
99
* the parameter data from `docs/parameterData.json`.
@@ -102,21 +112,24 @@ function validateParams(p5, fn) {
102
112
* Where each array in `overloads` represents a set of valid overloaded
103
113
* parameters, and `?` is a shorthand for `Optional`.
104
114
*
105
- * TODO:
106
- * - [ ] Support for p5 constructors
107
- * - [ ] Support for more obscure types, such as `lerpPalette` and optional
108
- * objects in `p5.Geometry.computeNormals()`
109
- * (see https://github.com/processing/p5.js/pull/7186#discussion_r1724983249)
110
- *
111
- * @param {String } func - Name of the function.
115
+ * @method generateZodSchemasForFunc
116
+ * @param {String } func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add`
112
117
* @returns {z.ZodSchema } Zod schema
113
118
*/
114
- function generateZodSchemasForFunc ( func ) {
115
- // Expect global functions like `sin` and class methods like `p5.Vector.add`
116
- const ichDot = func . lastIndexOf ( '.' ) ;
117
- const funcName = func . slice ( ichDot + 1 ) ;
118
- const funcClass = func . slice ( 0 , ichDot !== - 1 ? ichDot : 0 ) || 'p5' ;
119
+ fn . generateZodSchemasForFunc = function ( func ) {
120
+ // A special case for `p5.Color.paletteLerp`, which has an unusual and
121
+ // complicated function signature not shared by any other function in p5.
122
+ if ( func === 'p5.Color.paletteLerp' ) {
123
+ return z . tuple ( [
124
+ z . array ( z . tuple ( [
125
+ z . instanceof ( p5 . Color ) ,
126
+ z . number ( )
127
+ ] ) ) ,
128
+ z . number ( )
129
+ ] ) ;
130
+ }
119
131
132
+ const { funcName, funcClass } = extractFuncNameAndClass ( func ) ;
120
133
let funcInfo = dataDoc [ funcClass ] [ funcName ] ;
121
134
122
135
let overloads = [ ] ;
@@ -140,9 +153,7 @@ function validateParams(p5, fn) {
140
153
}
141
154
// All p5 objects start with `p5` in the documentation, i.e. `p5.Camera`.
142
155
else if ( baseType . startsWith ( 'p5' ) ) {
143
- console . log ( 'type' , baseType ) ;
144
156
const className = baseType . substring ( baseType . indexOf ( '.' ) + 1 ) ;
145
- console . log ( 'className' , p5Constructors [ className ] ) ;
146
157
typeSchema = z . instanceof ( p5Constructors [ className ] ) ;
147
158
}
148
159
// For primitive types and web API objects.
@@ -230,34 +241,6 @@ function validateParams(p5, fn) {
230
241
: z . union ( overloadSchemas ) ;
231
242
}
232
243
233
- /**
234
- * This is a helper function to print out the Zod schema in a readable format.
235
- * This is for debugging purposes only and will be removed in the future.
236
- *
237
- * @param {z.ZodSchema } schema - Zod schema.
238
- * @param {number } indent - Indentation level.
239
- */
240
- function printZodSchema ( schema , indent = 0 ) {
241
- const i = ' ' . repeat ( indent ) ;
242
- const log = msg => console . log ( `${ i } ${ msg } ` ) ;
243
-
244
- if ( schema instanceof z . ZodUnion || schema instanceof z . ZodTuple ) {
245
- const type = schema instanceof z . ZodUnion ? 'Union' : 'Tuple' ;
246
- log ( `${ type } : [` ) ;
247
-
248
- const items = schema instanceof z . ZodUnion
249
- ? schema . _def . options
250
- : schema . items ;
251
- items . forEach ( ( item , index ) => {
252
- log ( ` ${ type === 'Union' ? 'Option' : 'Item' } ${ index + 1 } :` ) ;
253
- printZodSchema ( item , indent + 4 ) ;
254
- } ) ;
255
- log ( ']' ) ;
256
- } else {
257
- log ( schema . constructor . name ) ;
258
- }
259
- }
260
-
261
244
/**
262
245
* Finds the closest schema to the input arguments.
263
246
*
@@ -269,22 +252,45 @@ function validateParams(p5, fn) {
269
252
* @param {Array } args - User input arguments.
270
253
* @returns {z.ZodSchema } Closest schema matching the input arguments.
271
254
*/
272
- function findClosestSchema ( schema , args ) {
255
+ fn . findClosestSchema = function ( schema , args ) {
273
256
if ( ! ( schema instanceof z . ZodUnion ) ) {
274
257
return schema ;
275
258
}
276
259
277
260
// Helper function that scores how close the input arguments are to a schema.
278
261
// Lower score means closer match.
279
262
const scoreSchema = schema => {
263
+ let score = Infinity ;
280
264
if ( ! ( schema instanceof z . ZodTuple ) ) {
281
265
console . warn ( 'Schema below is not a tuple: ' ) ;
282
266
printZodSchema ( schema ) ;
283
- return Infinity ;
267
+ return score ;
284
268
}
285
269
270
+ const numArgs = args . length ;
286
271
const schemaItems = schema . items ;
287
- let score = Math . abs ( schemaItems . length - args . length ) * 2 ;
272
+ const numSchemaItems = schemaItems . length ;
273
+ const numRequiredSchemaItems = schemaItems . filter ( item => ! item . isOptional ( ) ) . length ;
274
+
275
+ if ( numArgs >= numRequiredSchemaItems && numArgs <= numSchemaItems ) {
276
+ score = 0 ;
277
+ }
278
+ // Here, give more weight to mismatch in number of arguments.
279
+ //
280
+ // For example, color() can either take [Number, Number?] or
281
+ // [Number, Number, Number, Number?] as list of parameters.
282
+ // If the user passed in 3 arguments, [10, undefined, undefined], it's
283
+ // more than likely that they intended to pass in 3 arguments, but the
284
+ // last two arguments are invalid.
285
+ //
286
+ // If there's no bias towards matching the number of arguments, the error
287
+ // message will show that we're expecting at most 2 arguments, but more
288
+ // are received.
289
+ else {
290
+ score = Math . abs (
291
+ numArgs < numRequiredSchemaItems ? numRequiredSchemaItems - numArgs : numArgs - numSchemaItems
292
+ ) * 4 ;
293
+ }
288
294
289
295
for ( let i = 0 ; i < Math . min ( schemaItems . length , args . length ) ; i ++ ) {
290
296
const paramSchema = schemaItems [ i ] ;
@@ -313,6 +319,118 @@ function validateParams(p5, fn) {
313
319
return closestSchema ;
314
320
}
315
321
322
+ /**
323
+ * Prints a friendly error message after parameter validation, if validation
324
+ * has failed.
325
+ *
326
+ * @method _friendlyParamError
327
+ * @private
328
+ * @param {z.ZodError } zodErrorObj - The Zod error object containing validation errors.
329
+ * @param {String } func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add`
330
+ * @returns {String } The friendly error message.
331
+ */
332
+ fn . friendlyParamError = function ( zodErrorObj , func ) {
333
+ let message ;
334
+ // The `zodErrorObj` might contain multiple errors of equal importance
335
+ // (after scoring the schema closeness in `findClosestSchema`). Here, we
336
+ // always print the first error so that user can work through the errors
337
+ // one by one.
338
+ let currentError = zodErrorObj . errors [ 0 ] ;
339
+
340
+ // Helper function to build a type mismatch message.
341
+ const buildTypeMismatchMessage = ( actualType , expectedTypeStr , position ) => {
342
+ const positionStr = position ? `at the ${ ordinals [ position ] } parameter` : '' ;
343
+ const actualTypeStr = actualType ? `, but received ${ actualType } ` : '' ;
344
+ return `Expected ${ expectedTypeStr } ${ positionStr } ${ actualTypeStr } ` ;
345
+ }
346
+
347
+ // Union errors occur when a parameter can be of multiple types but is not
348
+ // of any of them. In this case, aggregate all possible types and print
349
+ // a friendly error message that indicates what the expected types are at
350
+ // which position (position is not 0-indexed, for accessibility reasons).
351
+ const processUnionError = ( error ) => {
352
+ const expectedTypes = new Set ( ) ;
353
+ let actualType ;
354
+
355
+ error . unionErrors . forEach ( err => {
356
+ const issue = err . issues [ 0 ] ;
357
+ if ( issue ) {
358
+ if ( ! actualType ) {
359
+ actualType = issue . received ;
360
+ }
361
+
362
+ if ( issue . code === 'invalid_type' ) {
363
+ expectedTypes . add ( issue . expected ) ;
364
+ }
365
+ // The case for constants. Since we don't want to print out the actual
366
+ // constant values in the error message, the error message will
367
+ // direct users to the documentation.
368
+ else if ( issue . code === 'invalid_literal' ) {
369
+ expectedTypes . add ( "constant (please refer to documentation for allowed values)" ) ;
370
+ } else if ( issue . code === 'custom' ) {
371
+ const match = issue . message . match ( / I n p u t n o t i n s t a n c e o f ( \w + ) / ) ;
372
+ if ( match ) expectedTypes . add ( match [ 1 ] ) ;
373
+ }
374
+ }
375
+ } ) ;
376
+
377
+ if ( expectedTypes . size > 0 ) {
378
+ const expectedTypesStr = Array . from ( expectedTypes ) . join ( ' or ' ) ;
379
+ const position = error . path . join ( '.' ) ;
380
+
381
+ message = buildTypeMismatchMessage ( actualType , expectedTypesStr , position ) ;
382
+ }
383
+
384
+ return message ;
385
+ }
386
+
387
+ switch ( currentError . code ) {
388
+ case 'invalid_union' : {
389
+ processUnionError ( currentError ) ;
390
+ break ;
391
+ }
392
+ case 'too_small' : {
393
+ const minArgs = currentError . minimum ;
394
+ message = `Expected at least ${ minArgs } argument${ minArgs > 1 ? 's' : '' } , but received fewer` ;
395
+ break ;
396
+ }
397
+ case 'invalid_type' : {
398
+ message = buildTypeMismatchMessage ( currentError . received , currentError . expected , currentError . path . join ( '.' ) ) ;
399
+ break ;
400
+ }
401
+ case 'too_big' : {
402
+ const maxArgs = currentError . maximum ;
403
+ message = `Expected at most ${ maxArgs } argument${ maxArgs > 1 ? 's' : '' } , but received more` ;
404
+ break ;
405
+ }
406
+ default : {
407
+ console . log ( 'Zod error object' , currentError ) ;
408
+ }
409
+ }
410
+
411
+ // Let the user know which function is generating the error.
412
+ message += ` in ${ func } ().` ;
413
+
414
+ // Generates a link to the documentation based on the given function name.
415
+ // TODO: Check if the link is reachable before appending it to the error
416
+ // message.
417
+ const generateDocumentationLink = ( func ) => {
418
+ const { funcName, funcClass } = extractFuncNameAndClass ( func ) ;
419
+ const p5BaseUrl = 'https://p5js.org/reference' ;
420
+ const url = `${ p5BaseUrl } /${ funcClass } /${ funcName } ` ;
421
+
422
+ return url ;
423
+ }
424
+
425
+ if ( currentError . code === 'too_big' || currentError . code === 'too_small' ) {
426
+ const documentationLink = generateDocumentationLink ( func ) ;
427
+ message += ` For more information, see ${ documentationLink } .` ;
428
+ }
429
+
430
+ console . log ( message ) ;
431
+ return message ;
432
+ }
433
+
316
434
/**
317
435
* Runs parameter validation by matching the input parameters to Zod schemas
318
436
* generated from the parameter data from `docs/parameterData.json`.
@@ -322,9 +440,9 @@ function validateParams(p5, fn) {
322
440
* @returns {Object } The validation result.
323
441
* @returns {Boolean } result.success - Whether the validation was successful.
324
442
* @returns {any } [result.data] - The parsed data if validation was successful.
325
- * @returns {import('zod-validation-error').ZodValidationError } [result.error] - The validation error if validation failed.
443
+ * @returns {String } [result.error] - The validation error message if validation has failed.
326
444
*/
327
- fn . _validateParams = function ( func , args ) {
445
+ fn . validate = function ( func , args ) {
328
446
if ( p5 . disableFriendlyErrors ) {
329
447
return ; // skip FES
330
448
}
@@ -334,18 +452,17 @@ function validateParams(p5, fn) {
334
452
// user intended to call the function with non-undefined arguments. Skip
335
453
// regular workflow and return a friendly error message right away.
336
454
if ( Array . isArray ( args ) && args . every ( arg => arg === undefined ) ) {
337
- const undefinedError = new Error ( `All arguments for function ${ func } are undefined. There is likely an error in the code.` ) ;
338
- const zodUndefinedError = fromError ( undefinedError ) ;
455
+ const undefinedErrorMessage = `All arguments for ${ func } () are undefined. There is likely an error in the code.` ;
339
456
340
457
return {
341
458
success : false ,
342
- error : zodUndefinedError
459
+ error : undefinedErrorMessage
343
460
} ;
344
461
}
345
462
346
463
let funcSchemas = schemaRegistry . get ( func ) ;
347
464
if ( ! funcSchemas ) {
348
- funcSchemas = generateZodSchemasForFunc ( func ) ;
465
+ funcSchemas = fn . generateZodSchemasForFunc ( func ) ;
349
466
schemaRegistry . set ( func , funcSchemas ) ;
350
467
}
351
468
@@ -355,12 +472,13 @@ function validateParams(p5, fn) {
355
472
data : funcSchemas . parse ( args )
356
473
} ;
357
474
} catch ( error ) {
358
- const closestSchema = findClosestSchema ( funcSchemas , args ) ;
359
- const validationError = fromError ( closestSchema . safeParse ( args ) . error ) ;
475
+ const closestSchema = fn . findClosestSchema ( funcSchemas , args ) ;
476
+ const zodError = closestSchema . safeParse ( args ) . error ;
477
+ const errorMessage = fn . friendlyParamError ( zodError , func ) ;
360
478
361
479
return {
362
480
success : false ,
363
- error : validationError
481
+ error : errorMessage
364
482
} ;
365
483
}
366
484
} ;
@@ -370,5 +488,5 @@ export default validateParams;
370
488
371
489
if ( typeof p5 !== 'undefined' ) {
372
490
validateParams ( p5 , p5 . prototype ) ;
373
- p5 . prototype . _loadP5Constructors ( ) ;
491
+ p5 . prototype . loadP5Constructors ( ) ;
374
492
}
0 commit comments