Skip to content
16 changes: 15 additions & 1 deletion src/strands/p5.strands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -70,6 +70,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) {
Expand Down
95 changes: 94 additions & 1 deletion src/strands/strands_transpiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,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
Expand Down
49 changes: 49 additions & 0 deletions test/unit/strands/strands_transpiler.js
Original file line number Diff line number Diff line change
@@ -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');
});
});