Skip to content

Commit 34d41a5

Browse files
authored
Merge pull request #7291 from sproutleaf/dev-2.0
Final changes for parameter validation
2 parents 78df565 + 9bb71ef commit 34d41a5

File tree

5 files changed

+327
-165
lines changed

5 files changed

+327
-165
lines changed

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
"gifenc": "^1.0.3",
2828
"libtess": "^1.2.2",
2929
"omggif": "^1.0.10",
30-
"opentype.js": "^1.3.1",
31-
"zod-validation-error": "^3.3.1"
30+
"opentype.js": "^1.3.1"
3231
},
3332
"devDependencies": {
3433
"@rollup/plugin-commonjs": "^25.0.7",
@@ -83,4 +82,4 @@
8382
"pre-commit": "lint-staged"
8483
}
8584
}
86-
}
85+
}

src/core/friendly_errors/param_validator.js

Lines changed: 175 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import p5 from '../main.js';
66
import * as constants from '../constants.js';
77
import { z } from 'zod';
8-
import { fromError } from 'zod-validation-error';
98
import dataDoc from '../../../docs/parameterData.json';
109

1110
function validateParams(p5, fn) {
@@ -20,7 +19,7 @@ function validateParams(p5, fn) {
2019
// and so on.
2120
const p5Constructors = {};
2221

23-
fn._loadP5Constructors = function () {
22+
fn.loadP5Constructors = function () {
2423
// Make a list of all p5 classes to be used for argument validation
2524
// This must be done only when everything has loaded otherwise we get
2625
// an empty array
@@ -84,6 +83,17 @@ function validateParams(p5, fn) {
8483
// Add web API schemas to the schema map.
8584
Object.assign(schemaMap, webAPISchemas);
8685

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+
8797
/**
8898
* This is a helper function that generates Zod schemas for a function based on
8999
* the parameter data from `docs/parameterData.json`.
@@ -102,21 +112,24 @@ function validateParams(p5, fn) {
102112
* Where each array in `overloads` represents a set of valid overloaded
103113
* parameters, and `?` is a shorthand for `Optional`.
104114
*
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`
112117
* @returns {z.ZodSchema} Zod schema
113118
*/
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+
}
119131

132+
const { funcName, funcClass } = extractFuncNameAndClass(func);
120133
let funcInfo = dataDoc[funcClass][funcName];
121134

122135
let overloads = [];
@@ -140,9 +153,7 @@ function validateParams(p5, fn) {
140153
}
141154
// All p5 objects start with `p5` in the documentation, i.e. `p5.Camera`.
142155
else if (baseType.startsWith('p5')) {
143-
console.log('type', baseType);
144156
const className = baseType.substring(baseType.indexOf('.') + 1);
145-
console.log('className', p5Constructors[className]);
146157
typeSchema = z.instanceof(p5Constructors[className]);
147158
}
148159
// For primitive types and web API objects.
@@ -230,34 +241,6 @@ function validateParams(p5, fn) {
230241
: z.union(overloadSchemas);
231242
}
232243

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-
261244
/**
262245
* Finds the closest schema to the input arguments.
263246
*
@@ -269,22 +252,45 @@ function validateParams(p5, fn) {
269252
* @param {Array} args - User input arguments.
270253
* @returns {z.ZodSchema} Closest schema matching the input arguments.
271254
*/
272-
function findClosestSchema(schema, args) {
255+
fn.findClosestSchema = function (schema, args) {
273256
if (!(schema instanceof z.ZodUnion)) {
274257
return schema;
275258
}
276259

277260
// Helper function that scores how close the input arguments are to a schema.
278261
// Lower score means closer match.
279262
const scoreSchema = schema => {
263+
let score = Infinity;
280264
if (!(schema instanceof z.ZodTuple)) {
281265
console.warn('Schema below is not a tuple: ');
282266
printZodSchema(schema);
283-
return Infinity;
267+
return score;
284268
}
285269

270+
const numArgs = args.length;
286271
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+
}
288294

289295
for (let i = 0; i < Math.min(schemaItems.length, args.length); i++) {
290296
const paramSchema = schemaItems[i];
@@ -313,6 +319,118 @@ function validateParams(p5, fn) {
313319
return closestSchema;
314320
}
315321

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(/Input not instance of (\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+
316434
/**
317435
* Runs parameter validation by matching the input parameters to Zod schemas
318436
* generated from the parameter data from `docs/parameterData.json`.
@@ -322,9 +440,9 @@ function validateParams(p5, fn) {
322440
* @returns {Object} The validation result.
323441
* @returns {Boolean} result.success - Whether the validation was successful.
324442
* @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.
326444
*/
327-
fn._validateParams = function (func, args) {
445+
fn.validate = function (func, args) {
328446
if (p5.disableFriendlyErrors) {
329447
return; // skip FES
330448
}
@@ -334,18 +452,17 @@ function validateParams(p5, fn) {
334452
// user intended to call the function with non-undefined arguments. Skip
335453
// regular workflow and return a friendly error message right away.
336454
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.`;
339456

340457
return {
341458
success: false,
342-
error: zodUndefinedError
459+
error: undefinedErrorMessage
343460
};
344461
}
345462

346463
let funcSchemas = schemaRegistry.get(func);
347464
if (!funcSchemas) {
348-
funcSchemas = generateZodSchemasForFunc(func);
465+
funcSchemas = fn.generateZodSchemasForFunc(func);
349466
schemaRegistry.set(func, funcSchemas);
350467
}
351468

@@ -355,12 +472,13 @@ function validateParams(p5, fn) {
355472
data: funcSchemas.parse(args)
356473
};
357474
} 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);
360478

361479
return {
362480
success: false,
363-
error: validationError
481+
error: errorMessage
364482
};
365483
}
366484
};
@@ -370,5 +488,5 @@ export default validateParams;
370488

371489
if (typeof p5 !== 'undefined') {
372490
validateParams(p5, p5.prototype);
373-
p5.prototype._loadP5Constructors();
491+
p5.prototype.loadP5Constructors();
374492
}

test/js/chai_helpers.js

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import p5 from '../../src/app.js';
2-
import { ValidationError } from 'zod-validation-error';
32

43
// Setup chai
54
var expect = chai.expect;
@@ -12,7 +11,7 @@ assert.arrayApproximately = function (arr1, arr2, delta, desc) {
1211
}
1312
}
1413

15-
assert.deepCloseTo = function(actual, expected, digits = 4) {
14+
assert.deepCloseTo = function (actual, expected, digits = 4) {
1615
expect(actual.length).toBe(expected.length);
1716
for (let i = 0; i < actual.length; i++) {
1817
expect(actual[i]).withContext(`[${i}]`).toBeCloseTo(expected[i], digits);
@@ -27,14 +26,4 @@ assert.validationError = function (fn) {
2726
} else {
2827
assert.doesNotThrow(fn, Error, 'got unwanted exception');
2928
}
30-
};
31-
32-
// A custom assertion for validation results for the new parameter validation
33-
// system.
34-
assert.validationResult = function (result, expectSuccess) {
35-
if (expectSuccess) {
36-
assert.isTrue(result.success);
37-
} else {
38-
assert.instanceOf(result.error, ValidationError);
39-
}
40-
};
29+
};

0 commit comments

Comments
 (0)