diff --git a/package-lock.json b/package-lock.json index 96a28156d2..8faf4d39d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,14 @@ "version": "1.9.4", "license": "LGPL-2.1", "dependencies": { + "acorn": "^8.12.1", + "acorn-walk": "^8.3.4", "colorjs.io": "^0.5.2", "file-saver": "^1.3.8", "gifenc": "^1.0.3", "libtess": "^1.2.2", "omggif": "^1.0.10", - "opentype.js": "^1.3.1", - "zod-validation-error": "^3.3.1" + "opentype.js": "^1.3.1" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", @@ -828,6 +829,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2547,7 +2565,6 @@ "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2559,11 +2576,21 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -4563,6 +4590,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4603,17 +4647,27 @@ } }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.1.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -11606,21 +11660,11 @@ "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.3.1.tgz", - "integrity": "sha512-uFzCZz7FQis256dqw4AhPQgD6f3pzNca/Zh62RNELavlumQB3nDIUFbF5JQfFLcMbO1s02Q7Xg/gpcOBlEnYZA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.18.0" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -11632,4 +11676,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index ef709e7e47..37c0804100 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ }, "version": "1.9.4", "dependencies": { + "acorn": "^8.12.1", + "acorn-walk": "^8.3.4", "colorjs.io": "^0.5.2", "file-saver": "^1.3.8", "gifenc": "^1.0.3", diff --git a/preview/index.html b/preview/index.html index 847423dc4d..299cf2ccb0 100644 --- a/preview/index.html +++ b/preview/index.html @@ -1,5 +1,6 @@ + P5 test @@ -7,35 +8,37 @@ + - + new p5(sketch); + + \ No newline at end of file diff --git a/src/core/friendly_errors/index.js b/src/core/friendly_errors/index.js index 4cf7db60ba..8f1b0e56e0 100644 --- a/src/core/friendly_errors/index.js +++ b/src/core/friendly_errors/index.js @@ -1,5 +1,7 @@ import validateParams from './param_validator.js'; +import sketchVerifier from './sketch_verifier.js'; export default function (p5) { p5.registerAddon(validateParams); + p5.registerAddon(sketchVerifier); } \ No newline at end of file diff --git a/src/core/friendly_errors/sketch_verifier.js b/src/core/friendly_errors/sketch_verifier.js new file mode 100644 index 0000000000..f90eb4f204 --- /dev/null +++ b/src/core/friendly_errors/sketch_verifier.js @@ -0,0 +1,126 @@ +import * as acorn from 'acorn'; +import * as walk from 'acorn-walk'; + +/** + * @for p5 + * @requires core + */ +function sketchVerifier(p5, fn) { + /** + * Fetches the contents of a script element in the user's sketch. + * + * @method fetchScript + * @param {HTMLScriptElement} script + * @returns {Promise} + */ + fn.fetchScript = async function (script) { + if (script.src) { + try { + const contents = await fetch(script.src).then((res) => res.text()); + return contents; + } catch (error) { + // TODO: Handle CORS error here. + console.error('Error fetching script:', error); + return ''; + } + } else { + return script.textContent; + } + } + + /** + * Extracts the user's code from the script fetched. Note that this method + * assumes that the user's code is always the last script element in the + * sketch. + * + * @method getUserCode + * @returns {Promise} The user's code as a string. + */ + fn.getUserCode = async function () { + // TODO: think of a more robust way to get the user's code. Refer to + // https://github.com/processing/p5.js/pull/7293. + const scripts = document.querySelectorAll('script'); + const userCodeScript = scripts[scripts.length - 1]; + const userCode = await fn.fetchScript(userCodeScript); + + return userCode; + } + + /** + * Extracts the user-defined variables and functions from the user code with + * the help of Espree parser. + * + * @method extractUserDefinedVariablesAndFuncs + * @param {string} code - The code to extract variables and functions from. + * @returns {Object} An object containing the user's defined variables and functions. + * @returns {Array<{name: string, line: number}>} [userDefinitions.variables] Array of user-defined variable names and their line numbers. + * @returns {Array<{name: string, line: number}>} [userDefinitions.functions] Array of user-defined function names and their line numbers. + */ + fn.extractUserDefinedVariablesAndFuncs = function (code) { + const userDefinitions = { + variables: [], + functions: [] + }; + // The line numbers from the parser are consistently off by one, add + // `lineOffset` here to correct them. + const lineOffset = -1; + + try { + const ast = acorn.parse(code, { + ecmaVersion: 2021, + sourceType: 'module', + locations: true // This helps us get the line number. + }); + + walk.simple(ast, { + VariableDeclarator(node) { + if (node.id.type === 'Identifier') { + const category = node.init && ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.init.type) + ? 'functions' + : 'variables'; + userDefinitions[category].push({ + name: node.id.name, + line: node.loc.start.line + lineOffset + }); + } + }, + FunctionDeclaration(node) { + if (node.id && node.id.type === 'Identifier') { + userDefinitions.functions.push({ + name: node.id.name, + line: node.loc.start.line + lineOffset + }); + } + }, + // We consider class declarations to be a special form of variable + // declaration. + ClassDeclaration(node) { + if (node.id && node.id.type === 'Identifier') { + userDefinitions.variables.push({ + name: node.id.name, + line: node.loc.start.line + lineOffset + }); + } + } + }); + } catch (error) { + // TODO: Replace this with a friendly error message. + console.error('Error parsing code:', error); + } + + return userDefinitions; + } + + fn.run = async function () { + const userCode = await fn.getUserCode(); + const userDefinedVariablesAndFuncs = fn.extractUserDefinedVariablesAndFuncs(userCode); + + return userDefinedVariablesAndFuncs; + } +} + +export default sketchVerifier; + +if (typeof p5 !== 'undefined') { + sketchVerifier(p5, p5.prototype); +} \ No newline at end of file diff --git a/test/unit/core/sketch_overrides.js b/test/unit/core/sketch_overrides.js new file mode 100644 index 0000000000..cf16edbff7 --- /dev/null +++ b/test/unit/core/sketch_overrides.js @@ -0,0 +1,220 @@ +import sketchVerifier from '../../../src/core/friendly_errors/sketch_verifier.js'; + +suite('Validate Params', function () { + const mockP5 = { + _validateParameters: vi.fn() + }; + const mockP5Prototype = {}; + + beforeAll(function () { + sketchVerifier(mockP5, mockP5Prototype); + }); + + afterAll(function () { + }); + + suite('fetchScript()', function () { + const url = 'https://www.p5test.com/sketch.js'; + const code = 'p.createCanvas(200, 200);'; + + test('Fetches script content from src', async function () { + const mockFetch = vi.fn(() => + Promise.resolve({ + text: () => Promise.resolve(code) + }) + ); + vi.stubGlobal('fetch', mockFetch); + + const mockScript = { src: url }; + const result = await mockP5Prototype.fetchScript(mockScript); + + expect(mockFetch).toHaveBeenCalledWith(url); + expect(result).toBe(code); + + vi.unstubAllGlobals(); + }); + + test('Fetches code when there is no src attribute', async function () { + const mockScript = { textContent: code }; + const result = await mockP5Prototype.fetchScript(mockScript); + + expect(result).toBe(code); + }); + }); + + suite('getUserCode()', function () { + const userCode = "let c = p5.Color(20, 20, 20);"; + + test('fetches the last script element', async function () { + document.body.innerHTML = ` + + + + `; + + mockP5Prototype.fetchScript = vi.fn(() => Promise.resolve(userCode)); + + const result = await mockP5Prototype.getUserCode(); + + expect(mockP5Prototype.fetchScript).toHaveBeenCalledTimes(1); + expect(result).toBe(userCode); + }); + }); + + suite('extractUserDefinedVariablesAndFuncs()', function () { + test('Extracts user-defined variables and functions', function () { + const code = ` + let x = 5; + const y = 10; + var z = 15; + let v1, v2, v3 + function foo() {} + const bar = () => {}; + const baz = (x) => x * 2; + `; + + const result = mockP5Prototype.extractUserDefinedVariablesAndFuncs(code); + const expectedResult = { + "functions": [ + { + "line": 5, + "name": "foo", + }, + { + "line": 6, + "name": "bar", + }, + { + "line": 7, + "name": "baz", + }, + ], + "variables": [ + { + "line": 1, + "name": "x", + }, + { + "line": 2, + "name": "y", + }, + { + "line": 3, + "name": "z", + }, + { + "line": 4, + "name": "v1", + }, + { + "line": 4, + "name": "v2", + }, + { + "line": 4, + "name": "v3", + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + // Sketch verifier should ignore the following types of lines: + // - Comments (both single line and multi-line) + // - Function calls + // - Non-declaration code + test('Ignores other lines', function () { + const code = ` + // This is a comment + let x = 5; + /* This is a multi-line comment. + * This is a multi-line comment. + */ + const y = 10; + console.log("This is a statement"); + foo(5); + p5.Math.random(); + if (true) { + let z = 15; + } + for (let i = 0; i < 5; i++) {} + `; + + const result = mockP5Prototype.extractUserDefinedVariablesAndFuncs(code); + const expectedResult = { + "functions": [], + "variables": [ + { + "line": 2, + "name": "x", + }, + { + "line": 6, + "name": "y", + }, + { + "line": 11, + "name": "z", + }, + { + "line": 13, + "name": "i", + }, + ], + }; + + expect(result).toEqual(expectedResult); + }); + + test('Handles parsing errors', function () { + const invalidCode = 'let x = ;'; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + + const result = mockP5Prototype.extractUserDefinedVariablesAndFuncs(invalidCode); + + expect(consoleSpy).toHaveBeenCalled(); + expect(result).toEqual({ variables: [], functions: [] }); + + consoleSpy.mockRestore(); + }); + }); + + suite('run()', function () { + test('Returns extracted variables and functions', async function () { + const mockScript = ` + let x = 5; + const y = 10; + function foo() {} + const bar = () => {}; + `; + mockP5Prototype.getUserCode = vi.fn(() => Promise.resolve(mockScript)); + + const result = await mockP5Prototype.run(); + const expectedResult = { + "functions": [ + { + "line": 3, + "name": "foo", + }, + { + "line": 4, + "name": "bar", + }, + ], + "variables": [ + { + "line": 1, + "name": "x", + }, + { + "line": 2, + "name": "y", + }, + ], + }; + + expect(mockP5Prototype.getUserCode).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/spec.js b/test/unit/spec.js index 995b152693..8df31317f2 100644 --- a/test/unit/spec.js +++ b/test/unit/spec.js @@ -13,6 +13,7 @@ var spec = { 'param_errors', 'preload', 'rendering', + 'sketch_overrides', 'structure', 'transform', 'version',