diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index 2eee0bfb64..8f6d9c2a40 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -6,7 +6,7 @@ */ import { glslBackend } from './strands_glslBackend'; -import { transpileStrandsToJS } from './strands_transpiler'; +import { transpileStrandsToJS, detectOutsideVariableReferences } from './strands_transpiler'; import { BlockType } from './ir_types'; import { createDirectedAcyclicGraph } from './ir_dag' @@ -72,6 +72,20 @@ function strands(p5, fn) { // TODO: expose this, is internal for debugging for now. const options = { parser: true, srcLocations: false }; + // 0. Detect outside variable references in uniforms (before transpilation) + if (options.parser) { + const sourceString = `(${shaderModifier.toString()})`; + const errors = detectOutsideVariableReferences(sourceString); + if (errors.length > 0) { + // Show errors to the user + for (const error of errors) { + p5._friendlyError( + `p5.strands: ${error.message}` + ); + } + } + } + // 1. Transpile from strands DSL to JS let strandsCallback; if (options.parser) { diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index f6d6167c4e..522b3aeb71 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -920,7 +920,100 @@ const ASTCallbacks = { return replaceInNode(node); } } - export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { + /** + * Analyzes strand code to detect outside variable references + * This runs before transpilation to provide helpful errors to users + * + * @param {string} sourceString - The strand code to analyze + * @returns {Array<{variable: string, message: string}>} - Array of errors if any + */ +export function detectOutsideVariableReferences(sourceString) { + try { + const ast = parse(sourceString, { ecmaVersion: 2021 }); + + const errors = []; + const declaredVars = new Set(); + + // First pass: collect all declared variables + ancestor(ast, { + VariableDeclaration(node) { + for (const declarator of node.declarations) { + if (declarator.id.type === 'Identifier') { + declaredVars.add(declarator.id.name); + } + } + } + }); + + // Second pass: check identifier references + ancestor(ast, { + Identifier(node, state, ancestors) { + const varName = node.name; + + // Skip built-ins and p5.strands functions + const ignoreNames = [ + '__p5', 'p5', 'window', 'global', 'undefined', 'null', 'this', 'arguments', + // p5.strands built-in functions + 'getWorldPosition', 'getWorldNormal', 'getWorldTangent', 'getWorldBinormal', + 'getLocalPosition', 'getLocalNormal', 'getLocalTangent', 'getLocalBinormal', + 'getUV', 'getColor', 'getTime', 'getDeltaTime', 'getFrameCount', + 'uniformFloat', 'uniformVec2', 'uniformVec3', 'uniformVec4', + 'uniformInt', 'uniformBool', 'uniformMat2', 'uniformMat3', 'uniformMat4' + ]; + if (ignoreNames.includes(varName)) return; + + // Skip if it's a property access (obj.prop) + const isProperty = ancestors.some(anc => + anc.type === 'MemberExpression' && anc.property === node + ); + if (isProperty) return; + + // Skip if it's a function parameter + // Find the immediate function scope and check if this identifier is a parameter + for (let i = ancestors.length - 1; i >= 0; i--) { + const anc = ancestors[i]; + if (anc.type === 'FunctionDeclaration' || + anc.type === 'FunctionExpression' || + anc.type === 'ArrowFunctionExpression') { + if (anc.params && anc.params.some(param => param.name === varName)) { + return; // It's a function parameter + } + break; // Only check the immediate function scope + } + } + + // Skip if it's its own declaration + const isDeclaration = ancestors.some(anc => + anc.type === 'VariableDeclarator' && anc.id === node + ); + if (isDeclaration) return; + + // Check if we're inside a uniform callback (OK to access outer scope) + const inUniformCallback = ancestors.some(anc => + anc.type === 'CallExpression' && + anc.callee.type === 'Identifier' && + anc.callee.name.startsWith('uniform') + ); + if (inUniformCallback) return; // Allow outer scope access in uniform callbacks + + // Check if variable is declared + if (!declaredVars.has(varName)) { + errors.push({ + variable: varName, + message: `Variable "${varName}" is not declared in the strand context.` + }); + } + } + }); + + return errors; + } catch (error) { + // If parsing fails, return empty array - transpilation will catch it + return []; + } +} + +export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { const ast = parse(sourceString, { ecmaVersion: 2021, locations: srcLocations diff --git a/test/unit/strands/strands_transpiler.js b/test/unit/strands/strands_transpiler.js new file mode 100644 index 0000000000..90a6edf1f4 --- /dev/null +++ b/test/unit/strands/strands_transpiler.js @@ -0,0 +1,49 @@ +import { detectOutsideVariableReferences } from '../../../src/strands/strands_transpiler.js'; + +suite('Strands Transpiler - Outside Variable Detection', function() { + test('should allow outer scope variables in uniform callbacks', function() { + // OK: mouseX in uniform callback is allowed + const code = ` + const myUniform = uniformFloat(() => mouseX); + getWorldPosition((inputs) => { + inputs.position.x += myUniform; + return inputs; + }); + `; + + const errors = detectOutsideVariableReferences(code); + assert.equal(errors.length, 0, 'Should not error - mouseX is OK in uniform callback'); + }); + + test('should detect undeclared variable in strand code', function() { + // ERROR: mouseX in strand code is not declared + const code = ` + getWorldPosition((inputs) => { + inputs.position.x += mouseX; // mouseX not declared in strand! + return inputs; + }); + `; + + const errors = detectOutsideVariableReferences(code); + assert.ok(errors.length > 0, 'Should detect error'); + assert.ok(errors.some(e => e.variable === 'mouseX'), 'Should detect mouseX'); + }); + + test('should not error when variable is declared', function() { + const code = ` + let myVar = 5; + getWorldPosition((inputs) => { + inputs.position.x += myVar; // myVar is declared + return inputs; + }); + `; + + const errors = detectOutsideVariableReferences(code); + assert.equal(errors.length, 0, 'Should not detect errors'); + }); + + test('should handle empty code', function() { + const errors = detectOutsideVariableReferences(''); + assert.equal(errors.length, 0, 'Empty code should have no errors'); + }); +});