From cb5be9047def8777b07c9f4116d270fcc9c9cfca Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 24 Aug 2026 15:57:29 +0200 Subject: [PATCH 1/4] feat: Add destructuring objects --- .../src/rules/noUnsupportedSyntax.ts | 61 +++++++- .../tests/rules/noUnsupportedSyntax.test.ts | 131 +++++++++++++++++- packages/tinyest-for-wgsl/src/parsers.ts | 91 ++++++++---- .../tinyest-for-wgsl/tests/parsers.test.ts | 98 ++++++++++++- packages/tinyest/src/nodes.ts | 19 +-- packages/typegpu/src/resolutionCtx.ts | 10 +- .../typegpu/src/shared/normalizeMetadata.ts | 46 +++++- packages/typegpu/src/shared/tseynit.ts | 26 +++- packages/typegpu/src/tgsl/wgslGenerator.ts | 74 +++++++++- .../typegpu/tests/internal/metadata.test.ts | 39 ++++-- .../typegpu/tests/internal/tseynit.test.ts | 16 +++ .../typegpu/tests/tgsl/wgslGenerator.test.ts | 99 +++++++++++++ .../unplugin-typegpu/src/core/obfuscate.ts | 37 +++-- .../unplugin-typegpu/test/aliasing.test.ts | 15 +- .../unplugin-typegpu/test/obfuscation.test.ts | 48 ++++++- .../test/parser-options.test.ts | 5 +- .../test/tgsl-transpiling.test.ts | 36 +++-- .../test/use-gpu-directive.test.ts | 14 +- 18 files changed, 759 insertions(+), 106 deletions(-) diff --git a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts index 6bcd642c44..0dd065f0b3 100644 --- a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts +++ b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts @@ -29,8 +29,32 @@ export const noUnsupportedSyntax = createRule({ }); } + function validateFunctionParameters( + node: + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression + | TSESTree.FunctionDeclaration, + ) { + if (directives.getEnclosingTypegpuFunction() !== node) { + return; + } + + for (const parameter of node.params) { + if ( + parameter.type === 'Identifier' || + parameter.type === 'AssignmentPattern' || + (parameter.type === 'ObjectPattern' && isSupportedObjectBindingPattern(parameter)) + ) { + continue; + } + + report(parameter, 'unsupported function parameter binding pattern'); + } + } + return { ArrowFunctionExpression(node) { + validateFunctionParameters(node); if (directives.getDirectiveStack().at(-2)?.directives.includes('use gpu')) { report(node, 'arrow function'); } @@ -40,6 +64,12 @@ export const noUnsupportedSyntax = createRule({ if (!directives.getEnclosingTypegpuFunction()) { return; } + + if (node.left.type === 'ObjectPattern' || node.left.type === 'ArrayPattern') { + report(node.left, 'destructuring assignment'); + return; + } + if (unsupportedAssignmentOps.includes(node.operator)) { report(node, `assignment expression '${node.operator}'`); } @@ -104,12 +134,14 @@ export const noUnsupportedSyntax = createRule({ }, FunctionDeclaration(node) { + validateFunctionParameters(node); if (directives.getDirectiveStack().at(-2)?.directives.includes('use gpu')) { report(node, 'function declaration'); } }, FunctionExpression(node) { + validateFunctionParameters(node); if (directives.getDirectiveStack().at(-2)?.directives.includes('use gpu')) { report(node, 'function expression'); } @@ -225,8 +257,23 @@ export const noUnsupportedSyntax = createRule({ if (!directives.getEnclosingTypegpuFunction()) { return; } - if (node.id.type !== 'Identifier') { - report(node, 'variable declaration using destructuring'); + + const declarationParent = node.parent?.parent; + if ( + node.id.type === 'ObjectPattern' && + (declarationParent?.type === 'ForStatement' || + declarationParent?.type === 'ForOfStatement') + ) { + report(node.id, 'object destructuring in loop header'); + return; + } + + if (node.id.type === 'Identifier') { + return; + } + + if (node.id.type !== 'ObjectPattern' || !isSupportedObjectBindingPattern(node.id)) { + report(node, 'unsupported variable binding pattern'); } }, @@ -243,3 +290,13 @@ export const noUnsupportedSyntax = createRule({ const unsupportedAssignmentOps = ['&&=', '**=', '||=', '??=']; const unsupportedBinaryOps = ['==', '!=', 'in', 'instanceof', '|>']; const unsupportedUnaryOps = ['+', 'typeof', 'void', 'delete']; + +function isSupportedObjectBindingPattern(pattern: TSESTree.ObjectPattern): boolean { + return pattern.properties.every( + (prop) => + prop.type === 'Property' && + !prop.computed && + prop.key.type === 'Identifier' && + prop.value.type === 'Identifier', + ); +} diff --git a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts index 7ee0b6798a..7e3f4982f7 100644 --- a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts +++ b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts @@ -9,6 +9,8 @@ describe('noUnsupportedSyntax', () => { "const fn = () => { 'use gpu'; const x = Struct({ prop: 1}); }", "const fn = () => { 'use gpu'; let x = 1; }", "const cls = new (class { #priv = 1; fn = () => { 'use gpu'; const a = this.#priv; } } )()", + "const fn = () => { 'use gpu'; const { a } = obj; }", + "const fn = () => { 'use gpu'; const { a, b: renamed } = obj; }", ], invalid: [ { @@ -306,11 +308,134 @@ describe('noUnsupportedSyntax', () => { ], }, { - code: "const fn = () => { 'use gpu'; const { a } = obj; }", + code: "const fn = () => { 'use gpu'; const { nested: { a } } = obj; }", errors: [ { messageId: 'unexpected', - data: { snippet: '{ a } = obj', syntax: 'variable declaration using destructuring' }, + data: { + snippet: '{ nested: { a } } = obj', + syntax: 'unsupported variable binding pattern', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; const { a = 1 } = obj; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ a = 1 } = obj', + syntax: 'unsupported variable binding pattern', + }, + }, + { + messageId: 'unexpected', + data: { + snippet: 'a = 1', + syntax: 'assignment pattern (default parameter)', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; const { ...rest } = obj; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ ...rest } = obj', + syntax: 'unsupported variable binding pattern', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; const { [key]: a } = obj; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ [key]: a } = obj', + syntax: 'unsupported variable binding pattern', + }, + }, + { + messageId: 'unexpected', + data: { + snippet: '[key]: a', + syntax: 'computed property key', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; for (const { value } = source; value < 10;) {} }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ value }', + syntax: 'object destructuring in loop header', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; for (const { value } of source) {} }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ value }', + syntax: 'object destructuring in loop header', + }, + }, + ], + }, + { + code: "const fn = ([a]) => { 'use gpu'; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '[a]', + syntax: 'unsupported function parameter binding pattern', + }, + }, + ], + }, + { + code: "function fn({ nested: { a } }) { 'use gpu'; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ nested: { a } }', + syntax: 'unsupported function parameter binding pattern', + }, + }, + ], + }, + { + code: "const fn = () => { 'use gpu'; let a = 0; ({ a } = obj); }", + errors: [{ + messageId: 'unexpected', + data: { + snippet: '{ a }', + syntax: 'destructuring assignment', + }, + }], + }, + { + code: "const fn = function(...args) { 'use gpu'; }", + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '...args', + syntax: 'unsupported function parameter binding pattern', + }, }, ], }, @@ -319,7 +444,7 @@ describe('noUnsupportedSyntax', () => { errors: [ { messageId: 'unexpected', - data: { snippet: '[a] = arr', syntax: 'variable declaration using destructuring' }, + data: { snippet: '[a] = arr', syntax: 'unsupported variable binding pattern' }, }, ], }, diff --git a/packages/tinyest-for-wgsl/src/parsers.ts b/packages/tinyest-for-wgsl/src/parsers.ts index 9e17fef3c4..e61fd91674 100644 --- a/packages/tinyest-for-wgsl/src/parsers.ts +++ b/packages/tinyest-for-wgsl/src/parsers.ts @@ -1,7 +1,7 @@ import type * as babel from '@babel/types'; import type * as acorn from 'acorn'; import * as tinyest from 'tinyest'; -import { FuncParameterType } from 'tinyest'; +import { BindingPatternType, type BindingPattern } from 'tinyest'; import type { Context, JsNode, TranspilationResult } from './types.ts'; import { tryFindExternalChain } from './externals.ts'; @@ -11,6 +11,38 @@ const tsFallthrough = (ctx: Context, node: { expression: babel.Expression }): ti return transpile(ctx, node.expression); }; +function parseBindingPattern(node: babel.LVal | acorn.Pattern): BindingPattern { + if (node.type === 'Identifier') { + return { + type: BindingPatternType.identifier, + name: node.name, + }; + } + + if (node.type !== 'ObjectPattern') { + throw new Error(`Unsupported binding pattern: ${node.type}`); + } + + return { + type: BindingPatternType.destructuredObject, + props: node.properties.map((prop) => { + if ( + (prop.type !== 'Property' && prop.type !== 'ObjectProperty') || + prop.computed || + prop.key.type !== 'Identifier' || + prop.value.type !== 'Identifier' + ) { + throw new Error('Only simple object destructuring is currently supported.'); + } + + return { + name: prop.key.name, + alias: prop.value.name, + }; + }), + }; +} + const Transpilers: Partial<{ [Type in JsNode['type']]: ( ctx: Context, @@ -72,6 +104,10 @@ const Transpilers: Partial<{ }, AssignmentExpression(ctx, node) { + if (node.left.type === 'ObjectPattern' || node.left.type === 'ArrayPattern') { + throw new Error('Destructuring assignments are not supported.'); + } + const left = transpile(ctx, node.left) as tinyest.Expression; const right = transpile(ctx, node.right) as tinyest.Expression; return [NODE.assignmentExpr, left, node.operator as tinyest.AssignmentOperator, right]; @@ -178,15 +214,18 @@ const Transpilers: Partial<{ } const decl = node.declarations[0]; - ctx.ignoreExternalDepth++; - const id = transpile(ctx, decl.id); - ctx.ignoreExternalDepth--; - if (typeof id !== 'string') { - throw new Error('Invalid variable declaration, expected identifier.'); + if (decl.id.type === 'VoidPattern') { + throw new Error('Void patterns are not supported.'); } - ctx.stack[ctx.stack.length - 1]?.declaredNames.push(id); + const binding = parseBindingPattern(decl.id); + const declaredNames = + binding.type === BindingPatternType.identifier + ? [binding.name] + : binding.props.map((prop) => prop.alias); + + ctx.stack[ctx.stack.length - 1]?.declaredNames.push(...declaredNames); const init = decl.init ? (transpile(ctx, decl.init) as tinyest.Expression) : undefined; @@ -195,10 +234,10 @@ const Transpilers: Partial<{ } if (node.kind === 'const') { - return init !== undefined ? [NODE.const, id, init] : [NODE.const, id]; + return init !== undefined ? [NODE.const, binding, init] : [NODE.const, binding]; } - return init !== undefined ? [NODE.let, id, init] : [NODE.let, id]; + return init !== undefined ? [NODE.let, binding, init] : [NODE.let, binding]; }, IfStatement(ctx, node) { @@ -245,6 +284,13 @@ const Transpilers: Partial<{ }, ForStatement(ctx, node) { + if ( + node.init?.type === 'VariableDeclaration' && + node.init.declarations.some((declaration) => declaration.id.type === 'ObjectPattern') + ) { + throw new Error('Object destructuring in for loop initializers is not supported.'); + } + ctx.stack.push({ declaredNames: [] }); const init = node.init ? (transpile(ctx, node.init) as tinyest.Statement) : null; @@ -265,6 +311,13 @@ const Transpilers: Partial<{ }, ForOfStatement(ctx, node) { + if ( + node.left.type === 'VariableDeclaration' && + node.left.declarations.some((declaration) => declaration.id.type === 'ObjectPattern') + ) { + throw new Error('Object destructuring in for...of loops is not supported.'); + } + ctx.stack.push({ declaredNames: [] }); const loopVar = transpile(ctx, node.left) as tinyest.Const | tinyest.Let; @@ -387,23 +440,7 @@ export function extractFunctionParts(rootNode: JsNode): { | babel.ObjectPattern | acorn.ObjectPattern )[] - ).map((param) => - param.type === 'ObjectPattern' - ? { - type: FuncParameterType.destructuredObject, - props: param.properties.flatMap((prop) => - (prop.type === 'Property' || prop.type === 'ObjectProperty') && - prop.key.type === 'Identifier' && - prop.value.type === 'Identifier' - ? [{ name: prop.key.name, alias: prop.value.name }] - : [], - ), - } - : { - type: FuncParameterType.identifier, - name: param.name, - }, - ), + ).map(parseBindingPattern), body: functionNode.body, }; } @@ -418,7 +455,7 @@ export function transpileFn(rootNode: JsNode): TranspilationResult { stack: [ { declaredNames: params.flatMap((param) => - param.type === FuncParameterType.identifier + param.type === BindingPatternType.identifier ? param.name : param.props.map((prop) => prop.alias), ), diff --git a/packages/tinyest-for-wgsl/tests/parsers.test.ts b/packages/tinyest-for-wgsl/tests/parsers.test.ts index 7ef040a636..3a3d478643 100644 --- a/packages/tinyest-for-wgsl/tests/parsers.test.ts +++ b/packages/tinyest-for-wgsl/tests/parsers.test.ts @@ -18,7 +18,7 @@ describe('transpileFn', () => { expect(params).toStrictEqual([]); expect(JSON.stringify(body)).toMatchInlineSnapshot( - `"[0,[[13,"a","undefined"],[13,"b","Infinity"],[13,"c","NaN"]]]"`, + `"[0,[[13,{"type":"i","name":"a"},"undefined"],[13,{"type":"i","name":"b"},"Infinity"],[13,{"type":"i","name":"c"},"NaN"]]]"`, ); // These are identifiers, so they should be in externals. expect(externalNames).toMatchInlineSnapshot(` @@ -92,7 +92,7 @@ describe('transpileFn', () => { expect(params).toStrictEqual([]); expect(JSON.stringify(body)).toMatchInlineSnapshot( - `"[0,[[13,"a",[5,"0"]],[2,"c","=",[1,"a","+",[5,"2"]]]]]"`, + `"[0,[[13,{"type":"i","name":"a"},[5,"0"]],[2,"c","=",[1,"a","+",[5,"2"]]]]]"`, ); // Only 'c' is external, as 'a' is declared in the same scope. expect(externalNames).toMatchInlineSnapshot(` @@ -117,7 +117,7 @@ describe('transpileFn', () => { expect(params).toStrictEqual([]); expect(JSON.stringify(body)).toMatchInlineSnapshot( - `"[0,[[13,"a",[5,"0"]],[0,[[2,"c","=",[1,"a","+",[5,"2"]]]]]]]"`, + `"[0,[[13,{"type":"i","name":"a"},[5,"0"]],[0,[[2,"c","=",[1,"a","+",[5,"2"]]]]]]]"`, ); // Only 'c' is external, as 'a' is declared in the outer scope. expect(externalNames).toMatchInlineSnapshot(` @@ -313,7 +313,7 @@ describe('transpileFn', () => { `); expect(JSON.stringify(body)).toMatchInlineSnapshot( - `"[0,[[13,"a","ext.p"],[13,"b","ext.q.a"],[13,"c","ext.q.b"],[13,"d","ext.r.a"],[13,"e","ext.r"],[13,"f","ext.s"],[13,"g","ext.s.a"],[13,"h",[7,[6,"ext.t.fn",[]],"x"]],[13,"i",[7,[8,"ext.t.comp",[103,"computed"]],"x"]],[13,"j","ext.t"],[13,"k","ext.u"],[13,"l","ext"]]]"`, + `"[0,[[13,{"type":"i","name":"a"},"ext.p"],[13,{"type":"i","name":"b"},"ext.q.a"],[13,{"type":"i","name":"c"},"ext.q.b"],[13,{"type":"i","name":"d"},"ext.r.a"],[13,{"type":"i","name":"e"},"ext.r"],[13,{"type":"i","name":"f"},"ext.s"],[13,{"type":"i","name":"g"},"ext.s.a"],[13,{"type":"i","name":"h"},[7,[6,"ext.t.fn",[]],"x"]],[13,{"type":"i","name":"i"},[7,[8,"ext.t.comp",[103,"computed"]],"x"]],[13,{"type":"i","name":"j"},"ext.t"],[13,{"type":"i","name":"k"},"ext.u"],[13,{"type":"i","name":"l"},"ext"]]]"`, ); }), ); @@ -357,7 +357,7 @@ describe('transpileFn', () => { `); expect(JSON.stringify(body)).toMatchInlineSnapshot( - `"[0,[[13,"a","ext.value"],[13,"b","ext.config.multiplier"],[13,"c","ext.config.zero"],[13,"d","ext.config.multiplier"]]]"`, + `"[0,[[13,{"type":"i","name":"a"},"ext.value"],[13,{"type":"i","name":"b"},"ext.config.multiplier"],[13,{"type":"i","name":"c"},"ext.config.zero"],[13,{"type":"i","name":"d"},"ext.config.multiplier"]]]"`, ); }), ); @@ -390,4 +390,92 @@ describe('transpileFn', () => { `); }), ); + + it( + 'parses destructured variable declarations', + dualTest((parse) => { + const { body, externalNames } = transpileFn( + parse(`() => { + const { x, position: pos } = source; + return x + pos; + }`), + ); + + expect(externalNames).toStrictEqual(new Map([['source', 'source']])); + expect(JSON.stringify(body)).toMatchInlineSnapshot( + `"[0,[[13,{"type":"d","props":[{"name":"x","alias":"x"},{"name":"position","alias":"pos"}]},"source"],[10,[1,"x","+","pos"]]]]"`, + ); + }), + ); + + it( + 'rejects unsupported object destructuring patterns', + dualTest((parse) => { + const patterns = [ + '{ nested: { value } }', + '{ value = 1 }', + '{ ...rest }', + '{ [key]: value }', + '{ "value": alias }', + ]; + + for (const pattern of patterns) { + expect(() => + transpileFn( + parse(`() => { + const ${pattern} = source; + }`), + ), + ).toThrow('Only simple object destructuring is currently supported.'); + } + }), + ); + + it( + 'rejects array destructuring', + dualTest((parse) => { + expect(() => + transpileFn( + parse(`() => { + const [value] = source; + }`), + ), + ).toThrow('Unsupported binding pattern: ArrayPattern'); + }), + ); + + it( + 'rejects object destructuring in loop headers', + dualTest((parse) => { + expect(() => + transpileFn( + parse(`() => { + for (const { value } = source; value < 10;) {} + }`), + ), + ).toThrow('Object destructuring in for loop initializers is not supported.'); + + expect(() => + transpileFn( + parse(`() => { + for (const { value } of source) {} + }`), + ), + ).toThrow('Object destructuring in for...of loops is not supported.'); + }), + ); + + it( + 'rejects destructuring assignments', + dualTest((parse) => { + expect(() => + transpileFn( + parse(`() => { + let a = 0; + ({ a } = source); + }`), + ), + ).toThrow('Destructuring assignments are not supported.'); + }), + ); }); diff --git a/packages/tinyest/src/nodes.ts b/packages/tinyest/src/nodes.ts index fe86ed2300..4905f67c45 100644 --- a/packages/tinyest/src/nodes.ts +++ b/packages/tinyest/src/nodes.ts @@ -59,15 +59,15 @@ export type Block = readonly [type: NodeTypeCatalog['block'], Statement[]]; * Represents a let statement */ export type Let = - | readonly [type: NodeTypeCatalog['let'], identifier: string] - | readonly [type: NodeTypeCatalog['let'], identifier: string, value: Expression]; + | readonly [type: NodeTypeCatalog['let'], binding: BindingPattern] + | readonly [type: NodeTypeCatalog['let'], binding: BindingPattern, value: Expression]; /** * Represents a const statement */ export type Const = - | readonly [type: NodeTypeCatalog['const'], identifier: string] - | readonly [type: NodeTypeCatalog['const'], identifier: string, value: Expression]; + | readonly [type: NodeTypeCatalog['const'], binding: BindingPattern] + | readonly [type: NodeTypeCatalog['const'], binding: BindingPattern, value: Expression]; export type For = readonly [ type: NodeTypeCatalog['for'], @@ -258,20 +258,23 @@ export type Expression = export type AnyNode = Statement | Expression; -export const FuncParameterType = { +export const BindingPatternType = { identifier: 'i', destructuredObject: 'd', } as const; -export type FuncParameter = +export type BindingPattern = | { - type: typeof FuncParameterType.identifier; + type: typeof BindingPatternType.identifier; name: string; } | { - type: typeof FuncParameterType.destructuredObject; + type: typeof BindingPatternType.destructuredObject; props: { name: string; alias: string; }[]; }; + +export type FuncParameter = BindingPattern; +export const FuncParameterType = BindingPatternType; diff --git a/packages/typegpu/src/resolutionCtx.ts b/packages/typegpu/src/resolutionCtx.ts index b05bd26529..969d2ed1a9 100644 --- a/packages/typegpu/src/resolutionCtx.ts +++ b/packages/typegpu/src/resolutionCtx.ts @@ -57,7 +57,7 @@ import type { import { CodegenState, isSelfResolvable, NormalState, type FunctionArgument } from './types.ts'; import type { WgslEnableExtension } from './wgslExtensions.ts'; import { getName, hasTinyestMetadata, isNamable, setName } from './shared/meta.ts'; -import { FuncParameterType } from 'tinyest'; +import { BindingPatternType } from 'tinyest'; import { accessProp } from './tgsl/accessProp.ts'; import { createIoSchema } from './core/function/ioSchema.ts'; import { isShelllessImpl } from './core/function/shelllessImpl.ts'; @@ -622,7 +622,7 @@ export class ResolutionCtxImpl implements ResolutionCtx { args.push(structArg); } - if (firstParam?.type === FuncParameterType.destructuredObject) { + if (firstParam?.type === BindingPatternType.destructuredObject) { // Route each destructured prop to a positional arg or struct field. for (const { name, alias } of firstParam.props) { const argInfo = positionalArgs.find((a) => a.schemaKey === name); @@ -634,7 +634,7 @@ export class ResolutionCtxImpl implements ResolutionCtx { scope.argAccess[alias] = createArgumentPropAccess(structArg.access, name); } } - } else if (firstParam?.type === FuncParameterType.identifier) { + } else if (firstParam?.type === BindingPatternType.identifier) { // Create named arg snippets, then a proxy for property access routing. const proxyEntries: Array<{ schemaKey: string; arg: FunctionArgumentAccess }> = []; for (const a of positionalArgs) { @@ -673,7 +673,7 @@ export class ResolutionCtxImpl implements ResolutionCtx { : 'argument'; switch (astParam?.type) { - case FuncParameterType.identifier: { + case BindingPatternType.identifier: { const arg = createArgument( this.makeUniqueIdentifier(astParam.name, 'block'), argType, @@ -683,7 +683,7 @@ export class ResolutionCtxImpl implements ResolutionCtx { scope.argAccess[astParam.name] = arg.access; break; } - case FuncParameterType.destructuredObject: { + case BindingPatternType.destructuredObject: { const objArg = createArgument( this.makeUniqueIdentifier(`_arg_${i}`, 'block'), argType, diff --git a/packages/typegpu/src/shared/normalizeMetadata.ts b/packages/typegpu/src/shared/normalizeMetadata.ts index 0d821064f6..1ba4d0a8e6 100644 --- a/packages/typegpu/src/shared/normalizeMetadata.ts +++ b/packages/typegpu/src/shared/normalizeMetadata.ts @@ -1,4 +1,9 @@ -import type { Block, FuncParameter } from 'tinyest'; +import { + BindingPatternType, + NodeTypeCatalog as NODE, + type Block, + type FuncParameter, +} from 'tinyest'; import { safeStringify } from './stringify.ts'; export interface RawMetadataV1 { @@ -50,13 +55,46 @@ export function normalizeMetadata(meta: RawMetadata): Metadata { if (meta.v === 1) { const rawExternals = meta.externals; const externals = typeof rawExternals === 'function' ? rawExternals : () => rawExternals; - return { ...meta, externals }; + + const ast = { + ...meta.ast, + body: normalizeLegacyBindings(meta.ast.body) as Block, + }; + + return { ...meta, ast, externals }; } if (meta.v === 2) { - const externals = normalizeExternalsV2(meta?.externals); - return { ...meta, externals: () => externals }; + const externals = normalizeExternalsV2(meta.externals); + const ast = { + ...meta.ast, + body: normalizeLegacyBindings(meta.ast.body) as Block, + }; + return { + ...meta, + ast, + externals: () => externals, + }; } throw new Error(`Unrecognized TypeGPU metadata format: ${safeStringify(meta)}`); } + +function normalizeLegacyBindings(value: unknown): unknown { + if (!Array.isArray(value)) { + return value; + } + + const normalized = value.map(normalizeLegacyBindings); + if ( + (normalized[0] === NODE.let || normalized[0] === NODE.const) && + typeof normalized[1] === 'string' + ) { + normalized[1] = { + type: BindingPatternType.identifier, + name: normalized[1], + }; + } + + return normalized; +} diff --git a/packages/typegpu/src/shared/tseynit.ts b/packages/typegpu/src/shared/tseynit.ts index c272e988b5..87d330bf59 100644 --- a/packages/typegpu/src/shared/tseynit.ts +++ b/packages/typegpu/src/shared/tseynit.ts @@ -9,6 +9,18 @@ export function stringifyNode(node: tinyest.AnyNode): string { return stringifyStatement(node, ''); } +function stringifyBindingPattern(binding: tinyest.BindingPattern): string { + if (binding.type === tinyest.BindingPatternType.identifier) { + return binding.name; + } + + const props = binding.props.map(({ name, alias }) => + name === alias ? name : `${name}: ${alias}`, + ); + + return `{ ${props.join(', ')} }`; +} + function stringifyStatement(node: tinyest.Statement, ident: string): string { if (isExpression(node)) { return `${ident}${stringifyExpression(node, ident)};`; @@ -35,17 +47,19 @@ function stringifyStatement(node: tinyest.Statement, ident: string): string { } if (node[0] === NODE.let) { + const binding = stringifyBindingPattern(node[1]); if (node[2] !== undefined) { - return `${ident}let ${node[1]} = ${stringifyExpression(node[2], ident)};`; + return `${ident}let ${binding} = ${stringifyExpression(node[2], ident)};`; } - return `${ident}let ${node[1]};`; + return `${ident}let ${binding};`; } if (node[0] === NODE.const) { + const binding = stringifyBindingPattern(node[1]); if (node[2] !== undefined) { - return `${ident}const ${node[1]} = ${stringifyExpression(node[2], ident)};`; + return `${ident}const ${binding} = ${stringifyExpression(node[2], ident)};`; } - return `${ident}const ${node[1]};`; + return `${ident}const ${binding};`; } if (node[0] === NODE.for) { @@ -72,10 +86,10 @@ function stringifyStatement(node: tinyest.Statement, ident: string): string { if (node[0] === NODE.forOf) { const leftKind = node[1][0] === NODE.const ? 'const' : 'let'; - const leftName = node[1][1]; + const leftBinding = stringifyBindingPattern(node[1][1]); const right = stringifyExpression(node[2], ident); const body = stringifyStatement(node[3], ident); - return `${ident}for (${leftKind} ${leftName} of ${right}) ${body}`; + return `${ident}for (${leftKind} ${leftBinding} of ${right}) ${body}`; } assertExhaustive(node); diff --git a/packages/typegpu/src/tgsl/wgslGenerator.ts b/packages/typegpu/src/tgsl/wgslGenerator.ts index 242a4a7c5e..2190b589db 100644 --- a/packages/typegpu/src/tgsl/wgslGenerator.ts +++ b/packages/typegpu/src/tgsl/wgslGenerator.ts @@ -223,6 +223,7 @@ export class WgslGenerator implements ShaderGenerator { // used to detect `continue` and `break` nodes in loop body, as well as label // unrolled blocks with comments #unrollingChain: number[] = []; + #destructuringIndex = 0; // prototype properties declare languageKey: string; @@ -1307,8 +1308,52 @@ Try 'return ${typeStr}(${str});' instead. return `${this.ctx.pre}return;`; } + protected _objectDestructuringStatement( + kind: 'let' | 'const', + props: readonly { name: string; alias: string }[], + eqNode: tinyest.Expression, + ): ResolvedStatement { + let sourceNode: tinyest.Expression = eqNode; + let temporaryDeclaration: ResolvedStatement | undefined; + + if (typeof eqNode !== 'string') { + const temporaryId = `#destructured_${this.#destructuringIndex++}`; + temporaryDeclaration = this._constStatement([ + NODE.const, + { + type: tinyest.BindingPatternType.identifier, + name: temporaryId, + }, + eqNode, + ]); + sourceNode = temporaryId; + } + + const propertyDeclaration = props.map((prop) => { + const propertyAccess: tinyest.MemberAccess = [NODE.memberAccess, sourceNode, prop.name]; + + const binding: tinyest.BindingPattern = { + type: tinyest.BindingPatternType.identifier, + name: prop.alias, + }; + + return kind === 'const' + ? this._constStatement([NODE.const, binding, propertyAccess]) + : this._letStatement([NODE.let, binding, propertyAccess]); + }); + + const declarations = temporaryDeclaration + ? [temporaryDeclaration, ...propertyDeclaration] + : propertyDeclaration; + + return { + code: declarations.map((declaration) => declaration.code).join('\n'), + definesInNearestScope: true, + }; + } + protected _letStatement(statement: tinyest.Let): ResolvedStatement { - const [_, rawId, eqNode] = statement; + const [_, binding, eqNode] = statement; if (eqNode === undefined) { throw new Error( @@ -1316,6 +1361,11 @@ Try 'return ${typeStr}(${str});' instead. ); } + if (binding.type === tinyest.BindingPatternType.destructuredObject) { + return this._objectDestructuringStatement('let', binding.props, eqNode); + } + const rawId = binding.name; + const eq = this._expression(eqNode); if (eq.value instanceof RefOperator) { @@ -1381,7 +1431,7 @@ Try 'return ${typeStr}(${str});' instead. } protected _constStatement(statement: tinyest.Const): ResolvedStatement { - const [_, rawId, eqNode] = statement; + const [_, binding, eqNode] = statement; if (eqNode === undefined) { throw new Error( @@ -1389,6 +1439,11 @@ Try 'return ${typeStr}(${str});' instead. ); } + if (binding.type === tinyest.BindingPatternType.destructuredObject) { + return this._objectDestructuringStatement('const', binding.props, eqNode); + } + const rawId = binding.name; + const eq = this._expression(eqNode); if (eq.value instanceof RefOperator) { @@ -1617,6 +1672,14 @@ ${this.ctx.pre}else ${alternate}`, if (statement[0] === NODE.for) { const [_, init, condition, update, body] = statement; + + if ( + Array.isArray(init) && + (init[0] === NODE.let || init[0] === NODE.const) && + init[1].type === tinyest.BindingPatternType.destructuredObject + ) { + throw new WgslTypeError('Object destructuring in for loop initializers is not supported.'); + } const prevUnrollingChain = this.#unrollingChain; this.#unrollingChain = []; @@ -1680,7 +1743,12 @@ ${this.ctx.pre}else ${alternate}`, const shouldUnroll = iterableExpr.value instanceof UnrollableIterable; const iterableSnippet = shouldUnroll ? iterableExpr.value.snippet : iterableExpr; const range = forOfUtils.getRangeSnippets(this.ctx, iterableSnippet, shouldUnroll); - const originalLoopVarName = loopVar[1]; + const loopBinding = loopVar[1]; + if (loopBinding.type !== tinyest.BindingPatternType.identifier) { + throw new WgslTypeError('Destructuring in for..of loops is not supported yet.'); + } + + const originalLoopVarName = loopBinding.name; const blockified = blockifySingleStatement(body); if (shouldUnroll) { diff --git a/packages/typegpu/tests/internal/metadata.test.ts b/packages/typegpu/tests/internal/metadata.test.ts index 66f6dc8b0d..318a85e4a1 100644 --- a/packages/typegpu/tests/internal/metadata.test.ts +++ b/packages/typegpu/tests/internal/metadata.test.ts @@ -16,6 +16,9 @@ describe('meta', () => { ((globalThis as INTERNAL_GlobalExt).__TYPEGPU_META__ ??= new WeakMap()).set(fn, meta); } const NODE = tinyest.NodeTypeCatalog; + function legacyLet(name: string, value: tinyest.Expression): tinyest.Let { + return [NODE.let, name, value] as unknown as tinyest.Let; + } describe('normalization', () => { it('throws a readable error when metadata is missing', () => { @@ -40,7 +43,7 @@ describe('meta', () => { params: [], body: [ NODE.block, - [[NODE.let, 'a', [NODE.memberAccess, [NODE.memberAccess, 'EXT', 'N'], '$']]], + [legacyLet('a', [NODE.memberAccess, [NODE.memberAccess, 'EXT', 'N'], '$'])], ], externalNames: ['EXT'], }, @@ -69,7 +72,7 @@ describe('meta', () => { params: [], body: [ NODE.block, - [[NODE.let, 'a', [NODE.memberAccess, [NODE.memberAccess, 'EXT', 'N'], '$']]], + [legacyLet('a', [NODE.memberAccess, [NODE.memberAccess, 'EXT', 'N'], '$'])], ], externalNames: ['EXT'], }, @@ -96,7 +99,7 @@ describe('meta', () => { externals: { 'EXT.O.N': () => EXT.O.N }, ast: { params: [], - body: [NODE.block, [[NODE.let, 'a', [NODE.memberAccess, 'EXT.O.N', '$']]]], + body: [NODE.block, [legacyLet('a', [NODE.memberAccess, 'EXT.O.N', '$'])]], }, }; assignMetadata(fn, meta); @@ -214,11 +217,31 @@ describe('meta', () => { body: [ NODE.block, [ - [NODE.let, 'fn', [NODE.numericLiteral, '1']], - [NODE.let, 'if', [NODE.numericLiteral, '1']], - [NODE.let, 'for', [NODE.numericLiteral, '1']], - [NODE.let, 'let', [NODE.numericLiteral, '1']], - [NODE.let, 'var', [NODE.numericLiteral, '1']], + [ + NODE.let, + { type: tinyest.BindingPatternType.identifier, name: 'fn' }, + [NODE.numericLiteral, '1'], + ], + [ + NODE.let, + { type: tinyest.BindingPatternType.identifier, name: 'if' }, + [NODE.numericLiteral, '1'], + ], + [ + NODE.let, + { type: tinyest.BindingPatternType.identifier, name: 'for' }, + [NODE.numericLiteral, '1'], + ], + [ + NODE.let, + { type: tinyest.BindingPatternType.identifier, name: 'let' }, + [NODE.numericLiteral, '1'], + ], + [ + NODE.let, + { type: tinyest.BindingPatternType.identifier, name: 'var' }, + [NODE.numericLiteral, '1'], + ], ], ], }, diff --git a/packages/typegpu/tests/internal/tseynit.test.ts b/packages/typegpu/tests/internal/tseynit.test.ts index 8fe16839e9..b5187d11a4 100644 --- a/packages/typegpu/tests/internal/tseynit.test.ts +++ b/packages/typegpu/tests/internal/tseynit.test.ts @@ -339,5 +339,21 @@ describe('ast to JS transformation', () => { }" `); }); + + it('handles object destructuring declarations', () => { + const node: tinyest.Const = [ + N.const, + { + type: tinyest.BindingPatternType.destructuredObject, + props: [ + { name: 'position', alias: 'position' }, + { name: 'velocity', alias: 'vel' }, + ], + }, + 'particle', + ]; + + expect(stringifyNode(node)).toBe('const { position, velocity: vel } = particle;'); + }); }); }); diff --git a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts index 883cb66b88..c6d29f499d 100644 --- a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts +++ b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts @@ -2022,4 +2022,103 @@ describe('WgslGenerator', () => { expect(snippets[1]?.origin).toBe('constant'); expect(snippets[2]?.origin).toBe('runtime'); }); + + describe('object destructuring', () => { + it('destructures an identifier without a temporary', () => { + const Pair = d.struct({ + a: d.i32, + b: d.i32, + }); + + const fn = () => { + 'use gpu'; + + const pair = Pair({ a: 2, b: 3 }); + const { a, b: c } = pair; + + return a + c; + }; + + expect(tgpu.resolve([fn])).toMatchInlineSnapshot(` + "struct Pair { + a: i32, + b: i32, + } + + fn fn_1() -> i32 { + let pair = Pair(2i, 3i); + let a = pair.a; + let c = pair.b; + return (a + c); + }" + `); + }); + + it('evaluates an expression only once', () => { + const Pair = d.struct({ + a: d.i32, + b: d.i32, + }); + + const createPair = () => { + 'use gpu'; + return Pair({ a: 2, b: 3 }); + }; + + const fn = () => { + 'use gpu'; + const { a, b: renamed } = createPair(); + return a + renamed; + }; + + expect(tgpu.resolve([fn])).toMatchInlineSnapshot(` + "struct Pair { + a: i32, + b: i32, + } + + fn createPair() -> Pair { + return Pair(2i, 3i); + } + + fn fn_1() -> i32 { + let destructured_0 = createPair(); + let a = destructured_0.a; + let renamed = destructured_0.b; + return (a + renamed); + }" + `); + }); + + it('allows mutable destructured variables', () => { + const Pair = d.struct({ + a: d.i32, + b: d.i32, + }); + + const fn = () => { + 'use gpu'; + + let { a, b: renamed } = Pair({ a: 2, b: 3 }); + a += renamed; + + return a; + }; + + expect(tgpu.resolve([fn])).toMatchInlineSnapshot(` + "struct Pair { + a: i32, + b: i32, + } + + fn fn_1() -> i32 { + let destructured_0 = Pair(2i, 3i); + var a = destructured_0.a; + let renamed = destructured_0.b; + a += renamed; + return a; + }" + `); + }); + }); }); diff --git a/packages/unplugin-typegpu/src/core/obfuscate.ts b/packages/unplugin-typegpu/src/core/obfuscate.ts index 7a99648352..39a7ecea82 100644 --- a/packages/unplugin-typegpu/src/core/obfuscate.ts +++ b/packages/unplugin-typegpu/src/core/obfuscate.ts @@ -60,19 +60,28 @@ class Context { } } +function obfuscateBindingPattern( + ctx: Context, + binding: tinyest.BindingPattern, +): tinyest.BindingPattern { + if (binding.type === tinyest.BindingPatternType.identifier) { + return { + ...binding, + name: ctx.obfuscator.obfuscate(binding.name), + }; + } + + // We cannot obfuscate destructured names, because WGSL generation relies on these names (e.g. `$instanceIndex`). + return { + ...binding, + props: binding.props.map((prop) => ({ ...prop, alias: ctx.obfuscator.obfuscate(prop.alias) })), + }; +} + export function obfuscate(fn: ReturnType): ReturnType { const ctx = new Context(); - const params = fn.params.map((param) => { - if (param.type === 'i') { - return { ...param, name: ctx.obfuscator.obfuscate(param.name) }; - } - // We cannot obfuscate destructured names, because WGSL generation relies on these names (e.g. `$instanceIndex`). - return { - ...param, - props: param.props.map((prop) => ({ ...prop, alias: ctx.obfuscator.obfuscate(prop.alias) })), - }; - }); + const params = fn.params.map((param) => obfuscateBindingPattern(ctx, param)); const body = obf(ctx, fn.body); @@ -123,13 +132,13 @@ const visitors = { }, let(ctx: Context, node: tinyest.Let) { return node.length === 2 - ? [NODE.let, obf(ctx, node[1])] - : [NODE.let, obf(ctx, node[1]), obf(ctx, node[2])]; + ? [NODE.let, obfuscateBindingPattern(ctx, node[1])] + : [NODE.let, obfuscateBindingPattern(ctx, node[1]), obf(ctx, node[2])]; }, const(ctx: Context, node: tinyest.Const) { return node.length === 2 - ? [NODE.const, obf(ctx, node[1])] - : [NODE.const, obf(ctx, node[1]), obf(ctx, node[2])]; + ? [NODE.const, obfuscateBindingPattern(ctx, node[1])] + : [NODE.const, obfuscateBindingPattern(ctx, node[1]), obf(ctx, node[2])]; }, for(ctx: Context, node: tinyest.For) { return [NODE.for, obf(ctx, node[1]), obf(ctx, node[2]), obf(ctx, node[3]), obf(ctx, node[4])]; diff --git a/packages/unplugin-typegpu/test/aliasing.test.ts b/packages/unplugin-typegpu/test/aliasing.test.ts index d3adcf2b9b..6a55b696b9 100644 --- a/packages/unplugin-typegpu/test/aliasing.test.ts +++ b/packages/unplugin-typegpu/test/aliasing.test.ts @@ -20,7 +20,10 @@ describe('[BABEL] tgpu alias gathering', () => { name: undefined, ast: { params: [], - body: [0, [[13, "x", [1, [5, "2"], "+", [5, "2"]]]]] + body: [0, [[13, { + type: "i", + name: "x" + }, [1, [5, "2"], "+", [5, "2"]]]]] }, externals: {} }) && $.f)({}));" @@ -45,7 +48,10 @@ describe('[BABEL] tgpu alias gathering', () => { name: undefined, ast: { params: [], - body: [0, [[13, "x", [1, [5, "2"], "+", [5, "2"]]]]] + body: [0, [[13, { + type: "i", + name: "x" + }, [1, [5, "2"], "+", [5, "2"]]]]] }, externals: {} }) && $.f)({}));" @@ -70,7 +76,10 @@ describe('[BABEL] tgpu alias gathering', () => { name: undefined, ast: { params: [], - body: [0, [[13, "x", [1, [5, "2"], "+", [5, "2"]]]]] + body: [0, [[13, { + type: "i", + name: "x" + }, [1, [5, "2"], "+", [5, "2"]]]]] }, externals: {} }) && $.f)({}));" diff --git a/packages/unplugin-typegpu/test/obfuscation.test.ts b/packages/unplugin-typegpu/test/obfuscation.test.ts index e0f47931b6..c58373fb80 100644 --- a/packages/unplugin-typegpu/test/obfuscation.test.ts +++ b/packages/unplugin-typegpu/test/obfuscation.test.ts @@ -40,7 +40,10 @@ describe('plugin obfuscation', () => { type: "i", name: "a" }], - body: [0, [[13, "b", [5, "3"]], [10, [1, [1, "c", "+", "a"], "+", "b"]]]] + body: [0, [[13, { + type: "i", + name: "b" + }, [5, "3"]], [10, [1, [1, "c", "+", "a"], "+", "b"]]]] }, externals: { "c": () => external.n @@ -62,7 +65,7 @@ describe('plugin obfuscation', () => { }), { v: 2, name: "fn", - ast: {"params":[{"type":"i","name":"a"}],"body":[0,[[13,"b",[5,"3"]],[10,[1,[1,"c","+","a"],"+","b"]]]]}, + ast: {"params":[{"type":"i","name":"a"}],"body":[0,[[13,{"type":"i","name":"b"},[5,"3"]],[10,[1,[1,"c","+","a"],"+","b"]]]]}, externals: {"c":() => external.n} }) && $.f)({})); @@ -97,7 +100,16 @@ describe('plugin obfuscation', () => { name: "fn", ast: { params: [], - body: [0, [[13, "a", "b"], [13, "c", "d"], [13, "e", "f"]]] + body: [0, [[13, { + type: "i", + name: "a" + }, "b"], [13, { + type: "i", + name: "c" + }, "d"], [13, { + type: "i", + name: "e" + }, "f"]]] }, externals: { "b": () => undefined, @@ -117,7 +129,7 @@ describe('plugin obfuscation', () => { }), { v: 2, name: "fn", - ast: {"params":[],"body":[0,[[13,"a","b"],[13,"c","d"],[13,"e","f"]]]}, + ast: {"params":[],"body":[0,[[13,{"type":"i","name":"a"},"b"],[13,{"type":"i","name":"c"},"d"],[13,{"type":"i","name":"e"},"f"]]]}, externals: {"b":() => undefined,"d":() => Infinity,"f":() => NaN} }) && $.f)({})); @@ -596,4 +608,32 @@ describe('obfuscate', () => { expect(stringifiedBody).toContain('ab'); expect(externalNames).toMatchInlineSnapshot(`Map {}`); }); + + it('obfuscates destructured variable declarations', () => { + const code = `(source) => { + const { position, velocity: localVelocity } = source; + return position + localVelocity; + }`; + + const transpiled = transpileFn(parse(code)); + const { params, body, externalNames } = obfuscate(transpiled); + + expect(params).toMatchInlineSnapshot(` + [ + { + "name": "a", + "type": "i", + }, + ] + `); + + expect(stringifyNode(body)).toMatchInlineSnapshot(` + "{ + const { position: b, velocity: c } = a; + return b + c; + }" + `); + + expect(externalNames).toMatchInlineSnapshot(`Map {}`); + }); }); diff --git a/packages/unplugin-typegpu/test/parser-options.test.ts b/packages/unplugin-typegpu/test/parser-options.test.ts index e5b4b21fbf..d778a3b317 100644 --- a/packages/unplugin-typegpu/test/parser-options.test.ts +++ b/packages/unplugin-typegpu/test/parser-options.test.ts @@ -20,7 +20,10 @@ describe('[BABEL] parser options', () => { name: undefined, ast: { params: [], - body: [0, [[13, "x", [1, [5, "2"], "+", [5, "2"]]]]] + body: [0, [[13, { + type: "i", + name: "x" + }, [1, [5, "2"], "+", [5, "2"]]]]] }, externals: {} }) && $.f)({}));" diff --git a/packages/unplugin-typegpu/test/tgsl-transpiling.test.ts b/packages/unplugin-typegpu/test/tgsl-transpiling.test.ts index ab3f9b116c..df099e241d 100644 --- a/packages/unplugin-typegpu/test/tgsl-transpiling.test.ts +++ b/packages/unplugin-typegpu/test/tgsl-transpiling.test.ts @@ -44,7 +44,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { type: "i", name: "input" }], - body: [0, [[13, "tmp", [7, [7, "counter", "$"], "x"]], [2, [7, [7, "counter", "$"], "x"], "=", [7, [7, "counter", "$"], "y"]], [2, [7, [7, "counter", "$"], "y"], "+=", "tmp"], [2, [7, [7, "counter", "$"], "z"], "+=", [6, "d.f32", [[7, [7, "input", "num"], "x"]]]]]] + body: [0, [[13, { + type: "i", + name: "tmp" + }, [7, [7, "counter", "$"], "x"]], [2, [7, [7, "counter", "$"], "x"], "=", [7, [7, "counter", "$"], "y"]], [2, [7, [7, "counter", "$"], "y"], "+=", "tmp"], [2, [7, [7, "counter", "$"], "z"], "+=", [6, "d.f32", [[7, [7, "input", "num"], "x"]]]]]] }, externals: { "counter": () => counter, @@ -86,7 +89,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { type: "i", name: "input" }], - body: [0, [[13, "x", true]]] + body: [0, [[13, { + type: "i", + name: "x" + }, true]]] }, externals: {} }) && $.f)({})); @@ -97,7 +103,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { name: undefined, ast: { params: [], - body: [0, [[13, "y", [1, [5, "2"], "+", [5, "2"]]]]] + body: [0, [[13, { + type: "i", + name: "y" + }, [1, [5, "2"], "+", [5, "2"]]]]] }, externals: {} }) && $.f)({})); @@ -163,7 +172,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { type: "i", name: "input" }], - body: [0, [[13, "x", true]]] + body: [0, [[13, { + type: "i", + name: "x" + }, true]]] }, externals: {} }) && $.f)({})); @@ -179,7 +191,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { type: "i", name: "input" }], - body: [0, [[13, "x", true]]] + body: [0, [[13, { + type: "i", + name: "x" + }, true]]] }, externals: {} }) && $.f)({})); @@ -195,7 +210,10 @@ describe('[BABEL] plugin for transpiling tgsl functions to tinyest', () => { type: "i", name: "input" }], - body: [0, [[13, "x", true]]] + body: [0, [[13, { + type: "i", + name: "x" + }, true]]] }, externals: {} }) && $.f)({}));" @@ -284,7 +302,7 @@ describe('[ROLLUP] plugin for transpiling tgsl functions to tinyest', () => { }), { v: 2, name: undefined, - ast: {"params":[{"type":"i","name":"input"}],"body":[0,[[13,"tmp",[7,[7,"counter","$"],"x"]],[2,[7,[7,"counter","$"],"x"],"=",[7,[7,"counter","$"],"y"]],[2,[7,[7,"counter","$"],"y"],"+=","tmp"],[2,[7,[7,"counter","$"],"z"],"+=",[6,"d.f32",[[7,[7,"input","num"],"x"]]]]]]}, + ast: {"params":[{"type":"i","name":"input"}],"body":[0,[[13,{"type":"i","name":"tmp"},[7,[7,"counter","$"],"x"]],[2,[7,[7,"counter","$"],"x"],"=",[7,[7,"counter","$"],"y"]],[2,[7,[7,"counter","$"],"y"],"+=","tmp"],[2,[7,[7,"counter","$"],"z"],"+=",[6,"d.f32",[[7,[7,"input","num"],"x"]]]]]]}, externals: {"counter":() => counter,"d.f32":() => d.f32} }) && $.f)({}))); " @@ -316,7 +334,7 @@ describe('[ROLLUP] plugin for transpiling tgsl functions to tinyest', () => { }), { v: 2, name: undefined, - ast: {"params":[{"type":"i","name":"input"}],"body":[0,[[13,"x",true]]]}, + ast: {"params":[{"type":"i","name":"input"}],"body":[0,[[13,{"type":"i","name":"x"},true]]]}, externals: {} }) && $.f)({}))); @@ -324,7 +342,7 @@ describe('[ROLLUP] plugin for transpiling tgsl functions to tinyest', () => { }), { v: 2, name: undefined, - ast: {"params":[],"body":[0,[[13,"y",[1,[5,"2"],"+",[5,"2"]]]]]}, + ast: {"params":[],"body":[0,[[13,{"type":"i","name":"y"},[1,[5,"2"],"+",[5,"2"]]]]]}, externals: {} }) && $.f)({}))); diff --git a/packages/unplugin-typegpu/test/use-gpu-directive.test.ts b/packages/unplugin-typegpu/test/use-gpu-directive.test.ts index 8158d49d03..fe92fceca9 100644 --- a/packages/unplugin-typegpu/test/use-gpu-directive.test.ts +++ b/packages/unplugin-typegpu/test/use-gpu-directive.test.ts @@ -572,7 +572,10 @@ describe('marked object methods', () => { type: "i", name: "n" }], - body: [0, [[11, [1, "n", "<=", [5, "1"]], [0, [[10, false]]]], [14, [12, "i", [5, "2"]], [1, "i", "<", "n"], [102, "++", "i"], [0, [[11, [1, [6, "obj.mod", ["n", "i"]], "===", [5, "0"]], [0, [[10, false]]]]]]], [10, true]]] + body: [0, [[11, [1, "n", "<=", [5, "1"]], [0, [[10, false]]]], [14, [12, { + type: "i", + name: "i" + }, [5, "2"]], [1, "i", "<", "n"], [102, "++", "i"], [0, [[11, [1, [6, "obj.mod", ["n", "i"]], "===", [5, "0"]], [0, [[10, false]]]]]]], [10, true]]] }, externals: { "obj.mod": () => obj.mod @@ -613,7 +616,7 @@ describe('marked object methods', () => { }), { v: 2, name: "isPrime", - ast: {"params":[{"type":"i","name":"n"}],"body":[0,[[11,[1,"n","<=",[5,"1"]],[0,[[10,false]]]],[14,[12,"i",[5,"2"]],[1,"i","<","n"],[102,"++","i"],[0,[[11,[1,[6,"obj.mod",["n","i"]],"===",[5,"0"]],[0,[[10,false]]]]]]],[10,true]]]}, + ast: {"params":[{"type":"i","name":"n"}],"body":[0,[[11,[1,"n","<=",[5,"1"]],[0,[[10,false]]]],[14,[12,{"type":"i","name":"i"},[5,"2"]],[1,"i","<","n"],[102,"++","i"],[0,[[11,[1,[6,"obj.mod",["n","i"]],"===",[5,"0"]],[0,[[10,false]]]]]]],[10,true]]]}, externals: {"obj.mod":() => obj.mod} }) && $.f)({})); @@ -667,7 +670,10 @@ describe('transforms numeric operations', () => { type: "i", name: "b" }], - body: [0, [[12, "c", [1, [1, "a", "+", "b"], "+", [5, "2"]]], [2, "c", "+=", [1, [5, "2"], "*", "b"]], [2, [7, "countMutable", "$"], "+=", [5, "3"]]]] + body: [0, [[12, { + type: "i", + name: "c" + }, [1, [1, "a", "+", "b"], "+", [5, "2"]]], [2, "c", "+=", [1, [5, "2"], "*", "b"]], [2, [7, "countMutable", "$"], "+=", [5, "3"]]]] }, externals: { "countMutable": () => countMutable @@ -694,7 +700,7 @@ describe('transforms numeric operations', () => { }), { v: 2, name: "main", - ast: {"params":[{"type":"i","name":"a"},{"type":"i","name":"b"}],"body":[0,[[12,"c",[1,[1,"a","+","b"],"+",[5,"2"]]],[2,"c","+=",[1,[5,"2"],"*","b"]],[2,[7,"countMutable","$"],"+=",[5,"3"]]]]}, + ast: {"params":[{"type":"i","name":"a"},{"type":"i","name":"b"}],"body":[0,[[12,{"type":"i","name":"c"},[1,[1,"a","+","b"],"+",[5,"2"]]],[2,"c","+=",[1,[5,"2"],"*","b"]],[2,[7,"countMutable","$"],"+=",[5,"3"]]]]}, externals: {"countMutable":() => countMutable} }) && $.f)({})); From a5f23510e2b74f37fef73e07ad1e83ac08750f98 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 24 Aug 2026 16:17:44 +0200 Subject: [PATCH 2/4] fix: style --- .../eslint-plugin/src/rules/noUnsupportedSyntax.ts | 2 +- .../tests/rules/noUnsupportedSyntax.test.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts index 0dd065f0b3..5e0e59e0b6 100644 --- a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts +++ b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts @@ -64,7 +64,7 @@ export const noUnsupportedSyntax = createRule({ if (!directives.getEnclosingTypegpuFunction()) { return; } - + if (node.left.type === 'ObjectPattern' || node.left.type === 'ArrayPattern') { report(node.left, 'destructuring assignment'); return; diff --git a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts index 7e3f4982f7..bd5d1d3c35 100644 --- a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts +++ b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts @@ -419,13 +419,15 @@ describe('noUnsupportedSyntax', () => { }, { code: "const fn = () => { 'use gpu'; let a = 0; ({ a } = obj); }", - errors: [{ - messageId: 'unexpected', - data: { - snippet: '{ a }', - syntax: 'destructuring assignment', + errors: [ + { + messageId: 'unexpected', + data: { + snippet: '{ a }', + syntax: 'destructuring assignment', + }, }, - }], + ], }, { code: "const fn = function(...args) { 'use gpu'; }", From dcb148e4c762ecfcb64e6d119a73f031771465c7 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 24 Aug 2026 17:17:46 +0200 Subject: [PATCH 3/4] test: Cover conflicts with user identifiers named destructured_x --- .../typegpu/tests/tgsl/wgslGenerator.test.ts | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts index c6d29f499d..16c5382866 100644 --- a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts +++ b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts @@ -2032,10 +2032,7 @@ describe('WgslGenerator', () => { const fn = () => { 'use gpu'; - - const pair = Pair({ a: 2, b: 3 }); - const { a, b: c } = pair; - + const { a, b: c } = Pair({ a: 2, b: 3 }); return a + c; }; @@ -2046,9 +2043,9 @@ describe('WgslGenerator', () => { } fn fn_1() -> i32 { - let pair = Pair(2i, 3i); - let a = pair.a; - let c = pair.b; + let destructured_0 = Pair(2i, 3i); + let a = destructured_0.a; + let c = destructured_0.b; return (a + c); }" `); @@ -2090,6 +2087,44 @@ describe('WgslGenerator', () => { `); }); + it('does not conflict with user identifiers named destructured_x', () => { + const Pair = d.struct({ + a: d.i32, + b: d.i32, + }); + + const createPair = () => { + 'use gpu'; + return Pair({ a: 2, b: 3 }); + }; + + const fn = () => { + 'use gpu'; + const destructured_0 = 0; + const { a:x, b:y } = createPair(); + const destructured_0_1 = 1; + }; + + expect(tgpu.resolve([fn])).toMatchInlineSnapshot(` + "struct Pair { + a: i32, + b: i32, + } + + fn createPair() -> Pair { + return Pair(2i, 3i); + } + + fn fn_1() { + const destructured_0 = 0; + let destructured_0_1 = createPair(); + let x = destructured_0_1.a; + let y = destructured_0_1.b; + const destructured_0_1_1 = 1; + }" + `); + }); + it('allows mutable destructured variables', () => { const Pair = d.struct({ a: d.i32, From ad31fa6ad9f43d5f00fdb50e0103a9eeeeaadbc0 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 26 Aug 2026 15:45:21 +0200 Subject: [PATCH 4/4] fix: style --- .../eslint-plugin/src/rules/noUnsupportedSyntax.ts | 10 ++++------ packages/typegpu/tests/tgsl/wgslGenerator.test.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts index 5e0e59e0b6..9e0e48f5fd 100644 --- a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts +++ b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts @@ -41,14 +41,12 @@ export const noUnsupportedSyntax = createRule({ for (const parameter of node.params) { if ( - parameter.type === 'Identifier' || - parameter.type === 'AssignmentPattern' || - (parameter.type === 'ObjectPattern' && isSupportedObjectBindingPattern(parameter)) + parameter.type !== 'Identifier' && + parameter.type !== 'AssignmentPattern' && + (parameter.type !== 'ObjectPattern' || !isSupportedObjectBindingPattern(parameter)) ) { - continue; + report(parameter, 'unsupported function parameter binding pattern'); } - - report(parameter, 'unsupported function parameter binding pattern'); } } diff --git a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts index 16c5382866..e8199dc956 100644 --- a/packages/typegpu/tests/tgsl/wgslGenerator.test.ts +++ b/packages/typegpu/tests/tgsl/wgslGenerator.test.ts @@ -2101,7 +2101,7 @@ describe('WgslGenerator', () => { const fn = () => { 'use gpu'; const destructured_0 = 0; - const { a:x, b:y } = createPair(); + const { a: x, b: y } = createPair(); const destructured_0_1 = 1; };