Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
84 changes: 83 additions & 1 deletion src/strands/strands_transpiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,89 @@ 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 });

// Collect all variable declarations in the strand
const declaredVars = new Set();

// First pass: collect declared variables
ancestor(ast, {
VariableDeclaration(node, state, ancestors) {
for (const decl of node.declarations) {
if (decl.id.type === 'Identifier') {
declaredVars.add(decl.id.name);
}
}
}
});

const errors = [];

// Second pass: check identifier references
ancestor(ast, {
Identifier(node, state, ancestors) {
const varName = node.name;

// Skip built-ins
const ignoreNames = ['__p5', 'p5', 'window', 'global', 'undefined', 'null', 'this', 'arguments'];
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
const isParam = ancestors.some(anc =>
Copy link
Contributor

@davepagurek davepagurek Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this case aiming to catch? inputs in e.g. getWorldInputs((inputs) => { ... })?

(anc.type === 'FunctionDeclaration' ||
anc.type === 'FunctionExpression' ||
anc.type === 'ArrowFunctionExpression') &&
anc.params && anc.params.includes(node)
);
if (isParam) return;

// 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 this variable is declared anywhere in the strand
if (!declaredVars.has(varName)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what you had before with pushing/popping scopes was not a bad approach -- currently, it looks like this will pass, when it should not:

baseMaterialShader().modify(() => {
  getWorldInputs(() => {
    let i = 1
    inputs.position.x += i // OK, i is in scope
    return inputs
  })
  getFinalColor((color) => {
    color.r += i // Not OK, i is not in scope
    return color
  })
})

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
56 changes: 56 additions & 0 deletions test/unit/strands/strands_transpiler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { detectOutsideVariableReferences } from '../../../src/strands/strands_transpiler.js';
import { suite, test } from '../../../test/js/spec.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 = `
baseMaterialShader.modify(() => {
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 = `
baseMaterialShader.modify(() => {
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 = `
baseMaterialShader.modify(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is testing something different from what the function would actually receive. This looks like code that would be found in a p5 sketch, but just the contents of the modify callback is sent into the your function normally.

I think a good next step would be to try to do a full integration test by running yarn dev:global and editing the sketch in preview/global. If that works, you can also show the maintainers by building the library with npm run build, and then uploading lib/p5.js to a p5 web editor sketch so that others can run the same test. I suspect doing that will reveal some of the problems with the implementation more quickly than in review (but feel free to reach out if you run into issues while doing so!)

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');
});
});
Loading