From 79a51786a8b9b17789c3860ca82d3b2488f32edf Mon Sep 17 00:00:00 2001 From: Aquel32 Date: Tue, 10 Nov 2026 14:02:08 +0100 Subject: [PATCH 1/2] feat: template literal support --- .../src/examples/tests/log-test/index.ts | 35 ++++ .../src/rules/noUnsupportedSyntax.ts | 7 - .../tests/rules/noUnsupportedSyntax.test.ts | 18 -- packages/tinyest-for-wgsl/src/transpilers.ts | 5 + packages/tinyest/src/nodes.ts | 8 + packages/typegpu/src/shared/tseynit.ts | 19 +- .../typegpu/src/tgsl/generationHelpers.ts | 25 +++ packages/typegpu/src/tgsl/wgslGenerator.ts | 33 ++- .../typegpu/tests/tgsl/consoleLog.test.ts | 190 ++++++++++++++++++ .../unplugin-typegpu/src/core/obfuscate.ts | 3 + 10 files changed, 316 insertions(+), 27 deletions(-) diff --git a/apps/typegpu-docs/src/examples/tests/log-test/index.ts b/apps/typegpu-docs/src/examples/tests/log-test/index.ts index 35f411688e..971675cfe2 100644 --- a/apps/typegpu-docs/src/examples/tests/log-test/index.ts +++ b/apps/typegpu-docs/src/examples/tests/log-test/index.ts @@ -286,6 +286,41 @@ export const controls = defineControls({ }) .dispatchThreads(16), }, + 'Template literals': { + onButtonClick: () => + root + .createGuardedComputePipeline(() => { + 'use gpu'; + console.log(`Log with template literal: ${4 + 4}`); + }) + .dispatchThreads(), + }, + 'Template literals with helper function': { + onButtonClick: () => { + const helper = (n: number) => { + 'use gpu'; + return n; + }; + + return root + .createGuardedComputePipeline(() => { + 'use gpu'; + console.log(`Log with template literal: ${helper(4 + 16)}`); + }) + .dispatchThreads(); + }, + }, + 'Template literals with complex content': { + onButtonClick: () => + root + .createGuardedComputePipeline(() => { + 'use gpu'; + console.log( + `Log with template literal: \`first\` \n new line \n another line with slash: \\`, + ); + }) + .dispatchThreads(), + }, }); export function onCleanup() { diff --git a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts index be092ff776..64431e5080 100644 --- a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts +++ b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts @@ -154,13 +154,6 @@ export const noUnsupportedSyntax = createRule({ report(node, 'spread element'); }, - TemplateLiteral(node) { - if (!directives.getEnclosingTypegpuFunction()) { - return; - } - report(node, 'template literal'); - }, - ThrowStatement(node) { if (!directives.getEnclosingTypegpuFunction()) { return; diff --git a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts index 1e18b8139e..bd30f5f9c5 100644 --- a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts +++ b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts @@ -219,24 +219,6 @@ describe('noUnsupportedSyntax', () => { { messageId: 'unexpected', data: { snippet: '...arr', syntax: 'spread element' } }, ], }, - { - code: "const func = function() { 'use gpu'; const x = `hello`; }", - errors: [ - { - messageId: 'unexpected', - data: { snippet: '`hello`', syntax: 'template literal' }, - }, - ], - }, - { - code: "const fn = () => { 'use gpu'; const x = tag`hello`; }", - errors: [ - { - messageId: 'unexpected', - data: { snippet: '`hello`', syntax: 'template literal' }, - }, - ], - }, { code: "const fn = () => { 'use gpu'; throw new Error('x'); }", errors: [ diff --git a/packages/tinyest-for-wgsl/src/transpilers.ts b/packages/tinyest-for-wgsl/src/transpilers.ts index db0de4bd9e..2b1dbe7e07 100644 --- a/packages/tinyest-for-wgsl/src/transpilers.ts +++ b/packages/tinyest-for-wgsl/src/transpilers.ts @@ -63,6 +63,11 @@ export const baseTranspilers = { return [NODE.binaryExpr, left, node.operator as tinyest.BinaryOperator, right]; }, + TemplateLiteral(ctx, node, transpile) { + const expressions = node.expressions.map((expr) => transpile(ctx, expr) as tinyest.Expression); + return [NODE.templateLiteral, node.quasis.map((quasi) => quasi.value.raw), expressions]; + }, + LogicalExpression(ctx, node, transpile) { const left = transpile(ctx, node.left) as tinyest.Expression; const right = transpile(ctx, node.right) as tinyest.Expression; diff --git a/packages/tinyest/src/nodes.ts b/packages/tinyest/src/nodes.ts index 3d57bb0658..7c132aee33 100644 --- a/packages/tinyest/src/nodes.ts +++ b/packages/tinyest/src/nodes.ts @@ -36,6 +36,7 @@ export const NodeTypeCatalog = { conditionalExpr: 105, nullLiteral: 106, booleanLiteral: 107, + templateLiteral: 108, } as const; export type NodeTypeCatalog = typeof NodeTypeCatalog; @@ -261,6 +262,12 @@ export type Null = readonly [type: NodeTypeCatalog['nullLiteral']]; /** boolean can either be encoded as a node, or as a plain literal */ export type Bool = boolean | readonly [type: NodeTypeCatalog['booleanLiteral'], boolean]; +export type TemplateLiteral = readonly [ + type: NodeTypeCatalog['templateLiteral'], + quasis: string[], + expressions: Expression[], +]; + export type Literal = Num | Str | Bool | Null; export type Expression = @@ -277,6 +284,7 @@ export type Expression = | PreUpdate | PostUpdate | Call + | TemplateLiteral | Literal; export type AnyNode = Statement | Expression; diff --git a/packages/typegpu/src/shared/tseynit.ts b/packages/typegpu/src/shared/tseynit.ts index c9180215d0..b940fec878 100644 --- a/packages/typegpu/src/shared/tseynit.ts +++ b/packages/typegpu/src/shared/tseynit.ts @@ -201,6 +201,22 @@ function stringifyExpression(node: tinyest.Expression): string { return 'null'; } + if (node[0] === NODE.templateLiteral) { + const quasis = node[1]; + const expressions = node[2]; + + let result = '`'; + quasis.forEach((quasi, i) => { + result += quasi; + if (i < expressions.length && expressions[i]) { + const expression = expressions[i]; + result += '${' + stringifyExpression(expression) + '}'; + } + }); + + return result + '`'; + } + assertExhaustive(node); } @@ -228,7 +244,8 @@ function isExpression(node: tinyest.AnyNode): node is tinyest.Expression { node[0] === NODE.postUpdate || node[0] === NODE.objectExpr || node[0] === NODE.conditionalExpr || - node[0] === NODE.nullLiteral + node[0] === NODE.nullLiteral || + node[0] === NODE.templateLiteral ) { node satisfies tinyest.Expression; return true; diff --git a/packages/typegpu/src/tgsl/generationHelpers.ts b/packages/typegpu/src/tgsl/generationHelpers.ts index 5080ff2db9..28172bd869 100644 --- a/packages/typegpu/src/tgsl/generationHelpers.ts +++ b/packages/typegpu/src/tgsl/generationHelpers.ts @@ -144,3 +144,28 @@ export class ArrayExpression implements SelfResolvable, WithComptimeValue { return ctx.gen.typeInstantiation(this.type, this.elements); } } + +export class TemplateLiteralExpression implements WithComptimeValue { + readonly [$internal] = true; + readonly elements: Snippet[]; + + constructor(elements: Snippet[]) { + this.elements = elements; + } + + toString(): string { + return 'TemplateLiteralExpression'; + } + + [$comptimeValueOf](): ComptimeValue { + const values: unknown[] = []; + for (const elem of this.elements) { + const known = asComptime(elem); + if (!known) { + return { comptime: false }; + } + values.push(known.value); + } + return { comptime: true, value: values }; + } +} diff --git a/packages/typegpu/src/tgsl/wgslGenerator.ts b/packages/typegpu/src/tgsl/wgslGenerator.ts index 61c9bb36a1..2e6751a8ed 100644 --- a/packages/typegpu/src/tgsl/wgslGenerator.ts +++ b/packages/typegpu/src/tgsl/wgslGenerator.ts @@ -46,6 +46,7 @@ import { coerceToSnippet, concretize, numericLiteralToSnippet, + TemplateLiteralExpression, } from './generationHelpers.ts'; import { Swizzle } from './swizzle.ts'; import { accessIndex } from './accessIndex.ts'; @@ -523,7 +524,12 @@ export class WgslGenerator implements ShaderGenerator { const callee = mathToStd.get(_callee) ?? _callee; if (supportedLogOps().includes(callee)) { - return this.ctx.generateLog(callee, args); + return this.ctx.generateLog( + callee, + args.flatMap((arg) => + arg.value instanceof TemplateLiteralExpression ? arg.value.elements : [arg], + ), + ); } if (callee === constant) { @@ -1180,6 +1186,31 @@ The generated shader will evaluate them in declaration order.`, return snip(expression[1], UnknownData, /* origin */ 'constant', false); } + if (expression[0] === NODE.templateLiteral) { + const [_, quasis, expressions] = expression; + const expressionsSnippets = expressions.map((expr) => this._expression(expr)); + const allConstant = expressionsSnippets.every((value) => value.origin === 'constant'); + + const quasisSnippets = quasis.map((quasi) => + snip(quasi, UnknownData, /* origin */ 'constant', false), + ); + + const values = quasisSnippets.flatMap((quasi, i) => { + const expr = expressionsSnippets[i]; + if (expr) { + return [quasi, expr]; + } + return [quasi]; + }); + + return snip( + new TemplateLiteralExpression(values), + UnknownData, + /* origin */ allConstant ? 'constant' : 'runtime', + values.some((v) => v.possibleSideEffects), + ); + } + if (expression[0] === NODE.preUpdate) { throw new Error('Cannot use pre-updates in TypeGPU functions.'); } diff --git a/packages/typegpu/tests/tgsl/consoleLog.test.ts b/packages/typegpu/tests/tgsl/consoleLog.test.ts index 2ee43e28ef..f5e664327b 100644 --- a/packages/typegpu/tests/tgsl/consoleLog.test.ts +++ b/packages/typegpu/tests/tgsl/consoleLog.test.ts @@ -803,4 +803,194 @@ describe('WgslGenerator with console.log', () => { }" `); }); + + it('works with template literal', ({ root }) => { + const log = console.log; + + const helper = () => { + 'use gpu'; + return 4; + }; + + const myPipeline = root.createGuardedComputePipeline((x) => { + 'use gpu'; + log(`first ${4 + 4} last`); + }); + + expect(tgpu.resolve([myPipeline.pipeline])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var sizeUniform: vec3u; + + var dataBlockIndex: u32; + + @group(0) @binding(1) var indexBuffer: atomic; + + struct SerializedLogData { + id: u32, + serializedData: array, + } + + @group(0) @binding(2) var dataBuffer: array; + + var dataByteIndex: u32; + + fn nextByteIndex() -> u32 { + let i = dataByteIndex; + dataByteIndex = dataByteIndex + 1u; + return i; + } + + fn serializeI32(n: i32) { + dataBuffer[dataBlockIndex].serializedData[nextByteIndex()] = bitcast(n); + } + + fn log1serializer(_arg_0: i32) { + serializeI32(_arg_0); + } + + fn log1(_arg_0: i32) { + dataBlockIndex = atomicAdd(&indexBuffer, 1); + if (dataBlockIndex >= 64) { + return; + } + dataBuffer[dataBlockIndex].id = 1; + dataByteIndex = 0; + + log1serializer(_arg_0); + } + + fn wrappedCallback(x: u32, _arg_1: u32, _arg_2: u32) { + log1(8i); + } + + @compute @workgroup_size(256, 1, 1) fn mainCompute(@builtin(global_invocation_id) id: vec3u) { + if (any(id >= sizeUniform)) { + return; + } + wrappedCallback(id.x, id.y, id.z); + }" + `); + }); + + it('works with template literal that contains a function call', ({ root }) => { + const log = console.log; + + const helper = () => { + 'use gpu'; + return 4; + }; + + const myPipeline = root.createGuardedComputePipeline((x) => { + 'use gpu'; + log(`first ${4 + helper()} last`); + }); + + expect(tgpu.resolve([myPipeline.pipeline])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var sizeUniform: vec3u; + + fn helper() -> i32 { + return 4; + } + + var dataBlockIndex: u32; + + @group(0) @binding(1) var indexBuffer: atomic; + + struct SerializedLogData { + id: u32, + serializedData: array, + } + + @group(0) @binding(2) var dataBuffer: array; + + var dataByteIndex: u32; + + fn nextByteIndex() -> u32 { + let i = dataByteIndex; + dataByteIndex = dataByteIndex + 1u; + return i; + } + + fn serializeI32(n: i32) { + dataBuffer[dataBlockIndex].serializedData[nextByteIndex()] = bitcast(n); + } + + fn log1serializer(_arg_0: i32) { + serializeI32(_arg_0); + } + + fn log1(_arg_0: i32) { + dataBlockIndex = atomicAdd(&indexBuffer, 1); + if (dataBlockIndex >= 64) { + return; + } + dataBuffer[dataBlockIndex].id = 1; + dataByteIndex = 0; + + log1serializer(_arg_0); + } + + fn wrappedCallback(x: u32, _arg_1: u32, _arg_2: u32) { + log1((4i + helper())); + } + + @compute @workgroup_size(256, 1, 1) fn mainCompute(@builtin(global_invocation_id) id: vec3u) { + if (any(id >= sizeUniform)) { + return; + } + wrappedCallback(id.x, id.y, id.z); + }" + `); + }); + + it('works with template literal that contains complex quasis', ({ root }) => { + const log = console.log; + + const myPipeline = root.createGuardedComputePipeline((x) => { + 'use gpu'; + log(`Log with template literal: \`first\` \n new line \n another line with slash: \\`); + }); + + expect(tgpu.resolve([myPipeline.pipeline])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var sizeUniform: vec3u; + + var dataBlockIndex: u32; + + @group(0) @binding(1) var indexBuffer: atomic; + + struct SerializedLogData { + id: u32, + serializedData: array, + } + + @group(0) @binding(2) var dataBuffer: array; + + var dataByteIndex: u32; + + fn log1serializer() { + + } + + fn log1() { + dataBlockIndex = atomicAdd(&indexBuffer, 1); + if (dataBlockIndex >= 64) { + return; + } + dataBuffer[dataBlockIndex].id = 1; + dataByteIndex = 0; + + log1serializer(); + } + + fn wrappedCallback(x: u32, _arg_1: u32, _arg_2: u32) { + log1(); + } + + @compute @workgroup_size(256, 1, 1) fn mainCompute(@builtin(global_invocation_id) id: vec3u) { + if (any(id >= sizeUniform)) { + return; + } + wrappedCallback(id.x, id.y, id.z); + }" + `); + }); }); diff --git a/packages/unplugin-typegpu/src/core/obfuscate.ts b/packages/unplugin-typegpu/src/core/obfuscate.ts index 462dd5233d..eb7566d8cd 100644 --- a/packages/unplugin-typegpu/src/core/obfuscate.ts +++ b/packages/unplugin-typegpu/src/core/obfuscate.ts @@ -187,6 +187,9 @@ const visitors = { conditionalExpr(ctx: Context, node: tinyest.ConditionalExpression) { return [NODE.conditionalExpr, obf(ctx, node[1]), obf(ctx, node[2]), obf(ctx, node[3])]; }, + templateLiteral(ctx: Context, node: tinyest.TemplateLiteral) { + return [NODE.templateLiteral, node[1], node[2].map((expression) => obf(ctx, expression))]; + }, nullLiteral(_: Context, node: tinyest.Null) { return node; }, From 427d2e8ebc79d1e3f3b722637a0cf8df76b3ed31 Mon Sep 17 00:00:00 2001 From: Aquel32 Date: Tue, 10 Nov 2026 15:09:24 +0100 Subject: [PATCH 2/2] feat: quasi cook fallback --- packages/tinyest-for-wgsl/src/transpilers.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tinyest-for-wgsl/src/transpilers.ts b/packages/tinyest-for-wgsl/src/transpilers.ts index 2b1dbe7e07..db36d86374 100644 --- a/packages/tinyest-for-wgsl/src/transpilers.ts +++ b/packages/tinyest-for-wgsl/src/transpilers.ts @@ -65,7 +65,8 @@ export const baseTranspilers = { TemplateLiteral(ctx, node, transpile) { const expressions = node.expressions.map((expr) => transpile(ctx, expr) as tinyest.Expression); - return [NODE.templateLiteral, node.quasis.map((quasi) => quasi.value.raw), expressions]; + const quasis = node.quasis.map((quasi) => quasi.value.cooked ?? cookQuasis(quasi.value.raw)); + return [NODE.templateLiteral, quasis, expressions]; }, LogicalExpression(ctx, node, transpile) { @@ -434,3 +435,16 @@ function extractId(ident: tinyest.Identifier): string { } return ident[1]; } + +function cookQuasis(raw: string): string { + return raw + .replaceAll(/\\n/g, '\n') + .replaceAll(/\\r/g, '\r') + .replaceAll(/\\t/g, '\t') + .replaceAll(/\\b/g, '\b') + .replaceAll(/\\f/g, '\f') + .replaceAll(/\\v/g, '\v') + .replaceAll(/\\\\/g, '\\') + .replaceAll(/\\`/g, '`') + .replaceAll(/\\/g, '\\'); +}