diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index 97f23dfff4..d2c813a940 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -140,7 +140,7 @@ function validateParams(p5, fn, lifecycles) { * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` * @returns {z.ZodSchema} Zod schema */ - fn.generateZodSchemasForFunc = function (func) { + const generateZodSchemasForFunc = function (func) { const { funcName, funcClass } = extractFuncNameAndClass(func); let funcInfo = dataDoc[funcClass][funcName]; @@ -308,7 +308,7 @@ function validateParams(p5, fn, lifecycles) { * @param {Array} args - User input arguments. * @returns {z.ZodSchema} Closest schema matching the input arguments. */ - fn.findClosestSchema = function (schema, args) { + const findClosestSchema = function (schema, args) { if (!(schema instanceof z.ZodUnion)) { return schema; } @@ -389,7 +389,7 @@ function validateParams(p5, fn, lifecycles) { * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` * @returns {String} The friendly error message. */ - fn.friendlyParamError = function (zodErrorObj, func, args) { + const friendlyParamError = function (zodErrorObj, func, args) { let message = '🌸 p5.js says: '; let isVersionError = false; // The `zodErrorObj` might contain multiple errors of equal importance @@ -520,7 +520,7 @@ function validateParams(p5, fn, lifecycles) { * @returns {any} [result.data] - The parsed data if validation was successful. * @returns {String} [result.error] - The validation error message if validation has failed. */ - fn.validate = function (func, args) { + const validate = function (func, args) { if (p5.disableFriendlyErrors) { return; // skip FES } @@ -548,7 +548,7 @@ function validateParams(p5, fn, lifecycles) { let funcSchemas = schemaRegistry.get(func); if (!funcSchemas) { - funcSchemas = fn.generateZodSchemasForFunc(func); + funcSchemas = generateZodSchemasForFunc(func); if (!funcSchemas) return; schemaRegistry.set(func, funcSchemas); } @@ -559,9 +559,9 @@ function validateParams(p5, fn, lifecycles) { data: funcSchemas.parse(args) }; } catch (error) { - const closestSchema = fn.findClosestSchema(funcSchemas, args); + const closestSchema = findClosestSchema(funcSchemas, args); const zodError = closestSchema.safeParse(args).error; - const errorMessage = fn.friendlyParamError(zodError, func, args); + const errorMessage = friendlyParamError(zodError, func, args); return { success: false, @@ -570,25 +570,22 @@ function validateParams(p5, fn, lifecycles) { } }; - lifecycles.presetup = function(){ - loadP5Constructors(); + fn._validate = validate; // TEMP: For unit tests - if( - p5.disableParameterValidator !== true && - p5.disableFriendlyErrors !== true - ){ - const excludes = ['validate']; - for(const f in this){ - if(!excludes.includes(f) && !f.startsWith('_') && typeof this[f] === 'function'){ - const copy = this[f]; - - this[f] = function(...args) { - this.validate(f, args); - return copy.call(this, ...args); - }; + p5.decorateHelper( + /^(?!_).+$/, + function(target, { name }){ + return function(...args){ + if (!p5.disableFriendlyErrors && !p5.disableParameterValidator) { + validate(name, args); } - } + return target.call(this, ...args); + }; } + ); + + lifecycles.presetup = function(){ + loadP5Constructors(); }; } diff --git a/src/core/main.js b/src/core/main.js index 6cafe4d375..526171150c 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -48,6 +48,35 @@ class p5 { static _friendlyFileLoadError = () => {}; constructor(sketch, node) { + // Apply addon defined decorations + if(p5.decorations.size > 0){ + for (const [patternArray, decoration] of p5.decorations) { + for(const member in p5.prototype) { + // Member must be a function + if (typeof p5.prototype[member] !== 'function') continue; + + if (!patternArray.some(pattern => { + if (typeof pattern === 'string') { + return pattern === member; + } else if (pattern instanceof RegExp) { + return pattern.test(member); + } + })) continue; + + p5.prototype[member] = decoration(p5.prototype[member], { + kind: 'method', + name: member, + access: {}, + static: false, + private: false, + addInitializer(initializer){} + }); + } + } + + p5.decorations.clear(); + } + ////////////////////////////////////////////// // PRIVATE p5 PROPERTIES AND METHODS ////////////////////////////////////////////// @@ -77,122 +106,7 @@ class p5 { // ensure correct reporting of window dimensions this._updateWindowSize(); - const bindGlobal = property => { - if (property === 'constructor') return; - - // Common setter for all property types - const createSetter = () => newValue => { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: newValue, - writable: true - }); - if (!p5.disableFriendlyErrors) { - console.log(`You just changed the value of "${property}", which was a p5 global value. This could cause problems later if you're not careful.`); - } - }; - - // Check if this property has a getter on the instance or prototype - const instanceDescriptor = Object.getOwnPropertyDescriptor(this, property); - const prototypeDescriptor = Object.getOwnPropertyDescriptor(p5.prototype, property); - const hasGetter = (instanceDescriptor && instanceDescriptor.get) || - (prototypeDescriptor && prototypeDescriptor.get); - - // Only check if it's a function if it doesn't have a getter - // to avoid actually evaluating getters before things like the - // renderer are fully constructed - let isPrototypeFunction = false; - let isConstant = false; - let constantValue; - - if (!hasGetter) { - const prototypeValue = p5.prototype[property]; - isPrototypeFunction = typeof prototypeValue === 'function'; - - // Check if this is a true constant from the constants module - if (!isPrototypeFunction && constants[property] !== undefined) { - isConstant = true; - constantValue = prototypeValue; - } - } - - if (isPrototypeFunction) { - // For regular functions, cache the bound function - const boundFunction = p5.prototype[property].bind(this); - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: boundFunction, - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return boundFunction; - }, - set: createSetter() - }); - } - } else if (isConstant) { - // For constants, cache the value directly - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: constantValue, - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return constantValue; - }, - set: createSetter() - }); - } - } else if (hasGetter || !isPrototypeFunction) { - // For properties with getters or non-function properties, use lazy optimization - // On first access, determine the type and optimize subsequent accesses - let lastFunction = null; - let boundFunction = null; - let isFunction = null; // null = unknown, true = function, false = not function - - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get: () => { - const currentValue = this[property]; - - if (isFunction === null) { - // First access - determine type and optimize - isFunction = typeof currentValue === 'function'; - if (isFunction) { - lastFunction = currentValue; - boundFunction = currentValue.bind(this); - return boundFunction; - } else { - return currentValue; - } - } else if (isFunction) { - // Optimized function path - only rebind if function changed - if (currentValue !== lastFunction) { - lastFunction = currentValue; - boundFunction = currentValue.bind(this); - } - return boundFunction; - } else { - // Optimized non-function path - return currentValue; - } - }, - set: createSetter() - }); - } - }; + const bindGlobal = createBindGlobal(this); // If the user has created a global setup or draw function, // assume "global" mode and make everything global (i.e. on the window) if (!sketch) { @@ -259,6 +173,7 @@ class p5 { static registerAddon(addon) { const lifecycles = {}; + addon(p5, p5.prototype, lifecycles); const validLifecycles = Object.keys(p5.lifecycleHooks); @@ -269,6 +184,13 @@ class p5 { } } + static decorations = new Map(); + static decorateHelper(pattern, decoration){ + let patternArray = pattern; + if (!Array.isArray(pattern)) patternArray = [pattern]; + p5.decorations.set(patternArray, decoration); + } + #customActions = {}; _customActions = new Proxy({}, { get: (target, prop) => { @@ -511,6 +433,96 @@ class p5 { } } +// Global helper function for binding properties to window in global mode +function createBindGlobal(instance) { + return function bindGlobal(property) { + if (property === 'constructor') return; + + // Check if this property has a getter on the instance or prototype + const instanceDescriptor = Object.getOwnPropertyDescriptor( + instance, + property + ); + const prototypeDescriptor = Object.getOwnPropertyDescriptor( + p5.prototype, + property + ); + const hasGetter = (instanceDescriptor && instanceDescriptor.get) || + (prototypeDescriptor && prototypeDescriptor.get); + + // Only check if it's a function if it doesn't have a getter + // to avoid actually evaluating getters before things like the + // renderer are fully constructed + let isPrototypeFunction = false; + let isConstant = false; + let constantValue; + + if (!hasGetter) { + const prototypeValue = p5.prototype[property]; + isPrototypeFunction = typeof prototypeValue === 'function'; + + // Check if this is a true constant from the constants module + if (!isPrototypeFunction && constants[property] !== undefined) { + isConstant = true; + constantValue = prototypeValue; + } + } + + if (isPrototypeFunction) { + // For regular functions, cache the bound function + const boundFunction = p5.prototype[property].bind(instance); + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: boundFunction + }); + } else if (isConstant) { + // For constants, cache the value directly + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: constantValue + }); + } else if (hasGetter || !isPrototypeFunction) { + // For properties with getters or non-function properties, use lazy optimization + // On first access, determine the type and optimize subsequent accesses + let lastFunction = null; + let boundFunction = null; + let isFunction = null; // null = unknown, true = function, false = not function + + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + get: () => { + const currentValue = instance[property]; + + if (isFunction === null) { + // First access - determine type and optimize + isFunction = typeof currentValue === 'function'; + if (isFunction) { + lastFunction = currentValue; + boundFunction = currentValue.bind(instance); + return boundFunction; + } else { + return currentValue; + } + } else if (isFunction) { + // Optimized function path - only rebind if function changed + if (currentValue !== lastFunction) { + lastFunction = currentValue; + boundFunction = currentValue.bind(instance); + } + return boundFunction; + } else { + // Optimized non-function path + return currentValue; + } + } + }); + } + }; +} + // Attach constants to p5 prototype for (const k in constants) { p5.prototype[k] = constants[k]; @@ -745,8 +757,6 @@ for (const k in constants) { * * */ -p5.disableFriendlyErrors = false; - import transform from './transform'; import structure from './structure'; import environment from './environment'; diff --git a/test/unit/core/param_errors.js b/test/unit/core/param_errors.js index 247ae4a692..e37d2c4d8e 100644 --- a/test/unit/core/param_errors.js +++ b/test/unit/core/param_errors.js @@ -28,7 +28,8 @@ suite('Validate Params', function () { Graphics: function() { return 'mock p5.Graphics'; }, - _error: () => {} + _error: () => {}, + decorateHelper: () => {}, }; const mockP5Prototype = {}; @@ -48,7 +49,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ input }) => { - const result = mockP5Prototype.validate('saturation', input); + const result = mockP5Prototype._validate('saturation', input); assert.isTrue(result.success); }); }); @@ -62,7 +63,7 @@ suite('Validate Params', function () { ]; invalidInputs.forEach(({ input }) => { - const result = mockP5Prototype.validate('p5.saturation', input); + const result = mockP5Prototype._validate('p5.saturation', input); assert.isTrue(result.error.startsWith('🌸 p5.js says: Expected Color or array or string at the first parameter, but received')); }); }); @@ -76,7 +77,7 @@ suite('Validate Params', function () { validInputs.forEach(({ name, input }) => { test(`blendMode(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.blendMode', [input]); + const result = mockP5Prototype._validate('p5.blendMode', [input]); assert.isTrue(result.success); }); }); @@ -89,7 +90,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input }) => { test(`blendMode(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.blendMode', [input]); + const result = mockP5Prototype._validate('p5.blendMode', [input]); const expectedError = '🌸 p5.js says: Expected constant (please refer to documentation for allowed values) at the first parameter, but received ' + input + ' in p5.blendMode().'; assert.equal(result.error, expectedError); }); @@ -103,7 +104,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`arc(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.arc', input); + const result = mockP5Prototype._validate('p5.arc', input); assert.isTrue(result.success); }); }); @@ -118,7 +119,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, msg }) => { test(`arc(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.arc', input); + const result = mockP5Prototype._validate('p5.arc', input); assert.equal(result.error, msg); }); }); @@ -126,7 +127,7 @@ suite('Validate Params', function () { suite('validateParams: promise where no promise is expected', function () { test('image(): promise for first argument', function () { - const result = mockP5Prototype.validate('p5.image', [Promise.resolve(), 0, 0]); + const result = mockP5Prototype._validate('p5.image', [Promise.resolve(), 0, 0]); console.log(result); assert.equal( result.error, @@ -137,7 +138,7 @@ suite('Validate Params', function () { suite('validateParams: class, multi-types + optional numbers', function () { test('ambientLight(): no firendly-err-msg', function () { - const result = mockP5Prototype.validate('p5.ambientLight', [new mockP5.Color()]); + const result = mockP5Prototype._validate('p5.ambientLight', [new mockP5.Color()]); assert.isTrue(result.success); }); }); @@ -155,7 +156,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, fn, msg }) => { test(`${fn}(): ${name}`, () => { - const result = mockP5Prototype.validate(`p5.${fn}`, input); + const result = mockP5Prototype._validate(`p5.${fn}`, input); assert.equal(result.error, msg); }); }); @@ -173,7 +174,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ fn, name, input, msg }) => { test(`${fn}(): ${name}`, () => { - const result = mockP5Prototype.validate(`p5.${fn}`, input); + const result = mockP5Prototype._validate(`p5.${fn}`, input); assert.equal(result.error, msg); }); }); @@ -187,7 +188,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`color(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.color', input); + const result = mockP5Prototype._validate('p5.color', input); assert.isTrue(result.success); }); }); @@ -201,7 +202,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, msg }) => { test(`color(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.color', input); + const result = mockP5Prototype._validate('p5.color', input); assert.equal(result.error, msg); }); @@ -216,13 +217,13 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`${name}`, function () { - const result = mockP5Prototype.validate('p5.set', input); + const result = mockP5Prototype._validate('p5.set', input); assert.isTrue(result.success); }); }); test('set() with Boolean (invalid)', function () { - const result = mockP5Prototype.validate('p5.set', [0, 0, true]); + const result = mockP5Prototype._validate('p5.set', [0, 0, true]); assert.equal(result.error, '🌸 p5.js says: Expected number or array or object at the third parameter, but received boolean in p5.set().'); }); }); @@ -238,7 +239,7 @@ suite('Validate Params', function () { testCases.forEach(({ fn, name, input }) => { test(`${fn}(): ${name}`, function () { - const result = mockP5Prototype.validate(fn, input); + const result = mockP5Prototype._validate(fn, input); assert.isTrue(result.success); }); }); @@ -251,31 +252,31 @@ suite('Validate Params', function () { [new mockP5.Color(), 0.8], [new mockP5.Color(), 0.5] ]; - const result = mockP5Prototype.validate('p5.paletteLerp', [colorStops, 0.5]); + const result = mockP5Prototype._validate('p5.paletteLerp', [colorStops, 0.5]); assert.isTrue(result.success); }); }); suite('validateParams: rest arguments', function () { test('createVector(): works with no args', function() { - const result = mockP5Prototype.validate('p5.createVector', []); + const result = mockP5Prototype._validate('p5.createVector', []); assert.isTrue(result.success); }); test('createVector(): works with one number', function() { - const result = mockP5Prototype.validate('p5.createVector', [1]); + const result = mockP5Prototype._validate('p5.createVector', [1]); assert.isTrue(result.success); }); test('createVector(): works with many numbers', function() { - const result = mockP5Prototype.validate('p5.createVector', [1, 2, 3, 4]); + const result = mockP5Prototype._validate('p5.createVector', [1, 2, 3, 4]); assert.isTrue(result.success); }); test('createVector(): fails with a non-number', function() { - const result = mockP5Prototype.validate('p5.createVector', ['1']); + const result = mockP5Prototype._validate('p5.createVector', ['1']); assert.isFalse(result.success); }); test('createVector(): fails with any non-number', function() { - const result = mockP5Prototype.validate('p5.createVector', [1, 2, '3', 4]); + const result = mockP5Prototype._validate('p5.createVector', [1, 2, '3', 4]); assert.isFalse(result.success); }); }); -}); \ No newline at end of file +});