diff --git a/packages/typegpu/src/core/function/fnCore.ts b/packages/typegpu/src/core/function/fnCore.ts index 020207d21d..726fbac559 100644 --- a/packages/typegpu/src/core/function/fnCore.ts +++ b/packages/typegpu/src/core/function/fnCore.ts @@ -1,3 +1,4 @@ +import { stripSourceMap, type Block, type SourceMap } from 'tinyest'; import { getAttributesString } from '../../data/attributes.ts'; import { undecorate } from '../../data/dataTypes.ts'; import { type ResolvedSnippet, snip } from '../../data/snippet.ts'; @@ -221,6 +222,7 @@ export function createFnCore( "Missing metadata for tgpu.fn function body (either missing 'use gpu' directive, or misconfigured `unplugin-typegpu`)", ); } + const [body, _] = stripSourceMap(ast.body) as [Block, SourceMap]; // If an entrypoint implementation has a second argument, it represents the output schema. // We look at the identifier chosen by the user and add it to externals. @@ -242,7 +244,7 @@ export function createFnCore( entryInput, params: ast.params, returnType, - body: ast.body, + body, externalMap: mergeFunctionExternals(externals), }); diff --git a/packages/typegpu/src/shared/normalizeMetadata.ts b/packages/typegpu/src/shared/normalizeMetadata.ts index 0d821064f6..3ef2fe0456 100644 --- a/packages/typegpu/src/shared/normalizeMetadata.ts +++ b/packages/typegpu/src/shared/normalizeMetadata.ts @@ -1,4 +1,4 @@ -import type { Block, FuncParameter } from 'tinyest'; +import type { Block, FuncParameter, SourceMappedNode } from 'tinyest'; import { safeStringify } from './stringify.ts'; export interface RawMetadataV1 { @@ -13,7 +13,7 @@ export interface RawMetadataV1 { export interface RawMetadataV2 { v: 2; name: string; - ast: { params: FuncParameter[]; body: Block }; + ast: { params: FuncParameter[]; body: Block | SourceMappedNode }; externals: { [key: string]: () => unknown }; } @@ -26,7 +26,7 @@ export type RawMetadata = RawMetadataV1 | RawMetadataV2; * Holds normalized function metadata required for WGSL generation */ export interface Metadata { - ast: { params: FuncParameter[]; body: Block }; + ast: { params: FuncParameter[]; body: Block | SourceMappedNode }; externals: () => Record; } diff --git a/packages/typegpu/tests/internal/metadata.test.ts b/packages/typegpu/tests/internal/metadata.test.ts index 6cbd5f9885..52c712614f 100644 --- a/packages/typegpu/tests/internal/metadata.test.ts +++ b/packages/typegpu/tests/internal/metadata.test.ts @@ -9,6 +9,7 @@ import type { RawMetadataV1, RawMetadataV2, } from '../../src/shared/normalizeMetadata.ts'; +import type { Block, SourceMap } from 'tinyest'; // functions in this file intentionally have no 'use gpu' directive describe('meta', () => { @@ -311,4 +312,30 @@ describe('meta', () => { }" `); }); + + it('correctly strips source-mapped functions', () => { + const fn = () => {}; + const ast: Block = [ + NODE.block, + [[NODE.const, [NODE.identifier, 'a'], [NODE.numericLiteral, '1']]], + ]; + const sourceMap: SourceMap = new Map(); + sourceMap.get = () => [1, 1]; + const meta: RawMetadataV2 = { + v: 2, + name: 'fn', + externals: {}, + ast: { + params: [], + body: tinyest.embedSourceMap(ast, sourceMap), + }, + }; + assignMetadata(fn, meta); + + expect(tgpu.resolve([fn])).toMatchInlineSnapshot(` + "fn fn_1() { + const a = 1; + }" + `); + }); }); diff --git a/packages/typegpu/tests/internal/tseynit.test.ts b/packages/typegpu/tests/internal/tseynit.test.ts index bf6f17dc8b..1e9e6ae578 100644 --- a/packages/typegpu/tests/internal/tseynit.test.ts +++ b/packages/typegpu/tests/internal/tseynit.test.ts @@ -9,7 +9,7 @@ function getBodyAst(fn: () => void) { if (!meta) { throw new Error('Expected ast to be defined'); } - return meta.ast.body; + return tinyest.stripSourceMap(meta.ast.body)[0]; } describe('ast to JS transformation', () => { diff --git a/packages/unplugin-typegpu/package.json b/packages/unplugin-typegpu/package.json index 4b9c9d342c..d3e58bd385 100644 --- a/packages/unplugin-typegpu/package.json +++ b/packages/unplugin-typegpu/package.json @@ -335,6 +335,7 @@ "@babel/traverse": "^7.29.0", "@babel/types": "catalog:", "@jridgewell/remapping": "^2.3.5", + "@jridgewell/trace-mapping": "^0.3.31", "ast-kit": "^2.2.0", "defu": "^6.1.4", "magic-string": "^0.30.21", @@ -354,7 +355,8 @@ "@types/picomatch": "^4.0.1", "rollup": "catalog:build", "tsdown": "catalog:build", - "typescript": "catalog:types" + "typescript": "catalog:types", + "webpack": "^5.104.1" }, "peerDependencies": { "typegpu": "workspace:^" diff --git a/packages/unplugin-typegpu/src/babel.ts b/packages/unplugin-typegpu/src/babel.ts index e6529dd1b4..606cfd4bd1 100644 --- a/packages/unplugin-typegpu/src/babel.ts +++ b/packages/unplugin-typegpu/src/babel.ts @@ -1,15 +1,17 @@ import type { NodePath, TraverseOptions } from '@babel/traverse'; import defu from 'defu'; -import type { Externals, TranspilationResult } from 'tinyest-for-wgsl'; +import type { Externals } from 'tinyest-for-wgsl'; import * as t from '@babel/types'; import { METADATA_FORMAT_VERSION, type PluginState, + type PluginTranspilationResult, checkOpts, defaultOptions, functionVisitor, getBlockScope, initPluginState, + nodePosition, } from './core/common.ts'; import { createFilterForId } from './core/filter.ts'; @@ -42,7 +44,7 @@ function assignMetadata( this: PluginState, path: NodePath, name: string | undefined, - ast: TranspilationResult, + ast: PluginTranspilationResult, ): void { const metadata = t.objectExpression([ t.objectProperty(i('v'), t.numericLiteral(METADATA_FORMAT_VERSION)), @@ -166,6 +168,7 @@ export default function TypeGPUPlugin() { name: 'typegpu', pre(this: PluginState) { this.opts = checkOpts(defu(this.opts, defaultOptions)); + this.originalPositionFor = nodePosition; initPluginState(this, { warn: (message) => console.warn(message), assignMetadata, diff --git a/packages/unplugin-typegpu/src/core/common.ts b/packages/unplugin-typegpu/src/core/common.ts index e829e84fed..a2e0f147b4 100644 --- a/packages/unplugin-typegpu/src/core/common.ts +++ b/packages/unplugin-typegpu/src/core/common.ts @@ -5,6 +5,7 @@ import MagicString from 'magic-string'; import { transpileBabelFn, type TranspilationResult } from 'tinyest-for-wgsl'; import { getEmbeddedTypegpuMetadata } from './embeddedMetadata.ts'; import { obfuscate } from './obfuscate.ts'; +import { embedSourceMap, type Block, type SourceMappedNode } from 'tinyest'; /** * Each breaking change to the metadata format requires a bump to this number. @@ -36,6 +37,13 @@ export interface Options { */ unstable_obfuscate?: boolean; + /** + * Keep the source map info so that resolve errors link to an appropriate source. + * + * @default false + */ + unstable_sourceMaps?: boolean; + /** * Skipping files that don't contain "typegpu", "tgpu" or "use gpu". * In case this early pruning hinders transformation, you @@ -52,6 +60,11 @@ export function checkOpts(opts: T): T { `Options 'unstable_obfuscate' and 'autoNamingEnabled' cannot be enabled at the same time.`, ); } + if (opts.unstable_obfuscate && opts.unstable_sourceMaps) { + throw new Error( + `Options 'unstable_obfuscate' and 'unstable_sourceMaps' cannot be enabled at the same time.`, + ); + } return opts; } @@ -60,6 +73,10 @@ export type MetadatableFunction = | t.FunctionExpression | t.ArrowFunctionExpression; +export type PluginTranspilationResult = Omit & { + body: Block | SourceMappedNode; +}; + export interface TransformMethods { warn(message: string): void; @@ -96,7 +113,7 @@ export interface TransformMethods { this: PluginState, path: NodePath, name: string | undefined, - ast: TranspilationResult, + ast: PluginTranspilationResult, ): void; wrapInAutoName(this: PluginState, path: NodePath, name: string): void; @@ -126,8 +143,21 @@ export interface PluginState extends TransformMethods { opts: Required; inUseGpuScope: boolean; + + /** + * Used for source mapping. + * Babel keeps the correct combined source map on the node, + * but for rollup we need to calculate it ourself. + */ + originalPositionFor: NodePositionProvider; } +/** + * Resolves the position of a node in the file the user actually authored, + * or `undefined` if it cannot be mapped back. + */ +export type NodePositionProvider = (node: t.Node) => [line: number, column: number] | undefined; + export interface NodeLocation { start?: number | null; end?: number | null; @@ -155,6 +185,7 @@ export const defaultOptions = { autoNamingEnabled: true, earlyPruning: true, unstable_obfuscate: false, + unstable_sourceMaps: false, } satisfies Partial; /** @@ -456,6 +487,13 @@ const operators = { '%=': '__tsover_mod', }; +export const nodePosition: NodePositionProvider = (node) => { + if (!node.loc) { + return undefined; + } + return [node.loc.start.line, node.loc.start.column]; +}; + function containsUseGpuDirective( node: t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression, ): boolean { @@ -466,7 +504,7 @@ function containsUseGpuDirective( const fnNodeToTranspiledMap = new WeakMap< t.FunctionDeclaration | t.FunctionExpression | t.ArrowFunctionExpression, - TranspilationResult + PluginTranspilationResult >(); function functionOnExit( @@ -490,11 +528,18 @@ function functionOnExit( } function transpile( + ctx: PluginState, rootNode: Parameters[0], - obf: boolean, -): TranspilationResult { +): PluginTranspilationResult { + if (ctx.opts.unstable_sourceMaps) { + const result = transpileBabelFn(rootNode, { + verboseNodes: true, + sourceMap: ctx.originalPositionFor, + }); + return { ...result, body: embedSourceMap(result.body, result.sourceMap) }; + } const result = transpileBabelFn(rootNode); - if (obf) { + if (ctx.opts.unstable_obfuscate) { return obfuscate(result); } return result; @@ -558,7 +603,7 @@ export const functionVisitor: TraverseOptions = { ArrowFunctionExpression: { enter(path, state) { if (containsUseGpuDirective(path.node) && getEmbeddedTypegpuMetadata(path) === undefined) { - fnNodeToTranspiledMap.set(path.node, transpile(path.node, this.opts.unstable_obfuscate)); + fnNodeToTranspiledMap.set(path.node, transpile(this, path.node)); if (state.inUseGpuScope) { throw new Error(`Nesting 'use gpu' functions is not allowed`); } @@ -571,7 +616,7 @@ export const functionVisitor: TraverseOptions = { FunctionExpression: { enter(path, state) { if (containsUseGpuDirective(path.node) && getEmbeddedTypegpuMetadata(path) === undefined) { - fnNodeToTranspiledMap.set(path.node, transpile(path.node, this.opts.unstable_obfuscate)); + fnNodeToTranspiledMap.set(path.node, transpile(this, path.node)); if (state.inUseGpuScope) { throw new Error(`Nesting 'use gpu' functions is not allowed`); } @@ -584,7 +629,7 @@ export const functionVisitor: TraverseOptions = { FunctionDeclaration: { enter(path, state) { if (containsUseGpuDirective(path.node) && getEmbeddedTypegpuMetadata(path) === undefined) { - fnNodeToTranspiledMap.set(path.node, transpile(path.node, this.opts.unstable_obfuscate)); + fnNodeToTranspiledMap.set(path.node, transpile(this, path.node)); if (state.inUseGpuScope) { throw new Error(`Nesting 'use gpu' functions is not allowed`); } @@ -613,7 +658,7 @@ export const functionVisitor: TraverseOptions = { t.ArrowFunctionExpression | t.FunctionDeclaration | t.FunctionExpression >, getFunctionName(path.get('arguments.0')), - transpile(implementation, this.opts.unstable_obfuscate), + transpile(this, implementation), ); } } diff --git a/packages/unplugin-typegpu/src/core/factory.ts b/packages/unplugin-typegpu/src/core/factory.ts index 8c1a424dfa..4d18af17ff 100644 --- a/packages/unplugin-typegpu/src/core/factory.ts +++ b/packages/unplugin-typegpu/src/core/factory.ts @@ -3,7 +3,7 @@ import MagicString from 'magic-string'; import { getBabelParserOptions, getLang } from 'ast-kit'; import type { UnpluginBuildContext, UnpluginContext, UnpluginFactory } from 'unplugin'; import _traverse, { type NodePath } from '@babel/traverse'; -import type { Externals, TranspilationResult } from 'tinyest-for-wgsl'; +import type { Externals } from 'tinyest-for-wgsl'; import * as parser from '@babel/parser'; import * as t from '@babel/types'; import { @@ -14,9 +14,19 @@ import { getBlockScope, METADATA_FORMAT_VERSION, checkOpts, + nodePosition, } from './common.ts'; -import type { Options, UnpluginPluginState, MetadatableFunction, NodeLocation } from './common.ts'; +import type { + Options, + UnpluginPluginState, + MetadatableFunction, + NodeLocation, + NodePositionProvider, + PluginTranspilationResult, +} from './common.ts'; +import type { SourceMap } from 'rollup'; +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping'; // I love CommonJS 💔 let traverse = _traverse; @@ -42,7 +52,7 @@ function assignMetadata( this: UnpluginPluginState, path: NodePath, name: string | undefined, - ast: TranspilationResult, + ast: PluginTranspilationResult, ): void { const metadata = `{ v: ${METADATA_FORMAT_VERSION}, @@ -142,6 +152,63 @@ const NodeUtils = { }, }; +interface CombinedSourcemapContext { + getCombinedSourcemap(): SourceMap; +} + +function supportsCombinedSourcemap( + ctx: UnpluginBuildContext & UnpluginContext, +): ctx is UnpluginBuildContext & UnpluginContext & CombinedSourcemapContext { + return typeof (ctx as Partial).getCombinedSourcemap === 'function'; +} + +const warnAboutSourceMapsOnce = (() => { + let warned = false; + return () => { + if (!warned) { + console.warn(`\ +[unplugin-typegpu] The current bundler does not provide combined source maps (getCombinedSourcemap). +If another plugin modifies the code, source maps may point to modified locations.`); + warned = true; + } + }; +})(); + +function tryGetCombinedSourceMap( + ctx: UnpluginBuildContext & UnpluginContext, +): NodePositionProvider { + if (!supportsCombinedSourcemap(ctx)) { + warnAboutSourceMapsOnce(); + return nodePosition; + } + + const combinedMap = ctx.getCombinedSourcemap(); + // Rollup doesn't type the map properly. + if (combinedMap.version !== 3) { + console.warn(`Incompatible combined map version: ${combinedMap.version} and 3.`); + return nodePosition; + } + const tracer = new TraceMap({ + ...combinedMap, + version: 3, + sourcesContent: combinedMap.sourcesContent ?? [], + }); + + return (node) => { + if (!node.loc) { + return undefined; + } + const { line, column } = originalPositionFor(tracer, { + line: node.loc.start.line, + column: node.loc.start.column, + }); + if (line === null || column === null) { + return undefined; + } + return [line, column]; + }; +} + export const unpluginFactory = ((rawOptions, _meta) => { const options = checkOpts(defu(rawOptions, defaultOptions)); @@ -183,6 +250,9 @@ export const unpluginFactory = ((rawOptions, _meta) => { magicString, opts: options, ...NodeUtils, + originalPositionFor: options.unstable_sourceMaps + ? tryGetCombinedSourceMap(this) + : nodePosition, } as UnpluginPluginState; initPluginState(state, { diff --git a/packages/unplugin-typegpu/test/embedded-metadata.test.ts b/packages/unplugin-typegpu/test/embedded-metadata.test.ts index 1cf8487cf8..8bb8872655 100644 --- a/packages/unplugin-typegpu/test/embedded-metadata.test.ts +++ b/packages/unplugin-typegpu/test/embedded-metadata.test.ts @@ -86,7 +86,7 @@ describe('collects embedded TypeGPU metadata', () => { test('babel', () => { const metadata: EmbeddedTypegpuMetadata[] = []; - babelTransform(code, undefined, [createBabelMetadataCollector(metadata)]); + babelTransform(code, undefined, [], [createBabelMetadataCollector(metadata)]); expect(JSON.stringify(metadata)).toMatchInlineSnapshot( `"[{"v":2,"name":"fn1"},{"v":2,"name":"fn2"},{"v":2,"name":"fn3"}]"`, @@ -96,7 +96,7 @@ describe('collects embedded TypeGPU metadata', () => { test('rollup', async () => { const metadata: EmbeddedTypegpuMetadata[] = []; - await rollupTransform(code, undefined, [createRollupMetadataCollector(metadata)]); + await rollupTransform(code, undefined, [], [createRollupMetadataCollector(metadata)]); expect(JSON.stringify(metadata)).toMatchInlineSnapshot( `"[{"v":2,"name":"fn1"},{"v":2,"name":"fn2"},{"v":2,"name":"fn3"}]"`, diff --git a/packages/unplugin-typegpu/test/obfuscation.test.ts b/packages/unplugin-typegpu/test/obfuscation.test.ts index aa69082b59..bffe9694f4 100644 --- a/packages/unplugin-typegpu/test/obfuscation.test.ts +++ b/packages/unplugin-typegpu/test/obfuscation.test.ts @@ -151,6 +151,43 @@ describe('plugin obfuscation', () => { `[Error: Options 'unstable_obfuscate' and 'autoNamingEnabled' cannot be enabled at the same time.]`, ); }); + + test('[BABEL]', () => { + expect(() => + babelTransform('', { + autoNamingEnabled: false, + unstable_obfuscate: true, + unstable_sourceMaps: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: unknown file: Options 'unstable_obfuscate' and 'unstable_sourceMaps' cannot be enabled at the same time.]`, + ); + }); + + test('[ROLLUP]', async () => { + expect(() => + rollupPlugin({ + ...defaultOptions, + autoNamingEnabled: false, + unstable_obfuscate: true, + unstable_sourceMaps: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Options 'unstable_obfuscate' and 'unstable_sourceMaps' cannot be enabled at the same time.]`, + ); + }); + + test('[BUN]', async () => { + expect(() => + bunPlugin({ + autoNamingEnabled: false, + unstable_sourceMaps: true, + unstable_obfuscate: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Options 'unstable_obfuscate' and 'unstable_sourceMaps' cannot be enabled at the same time.]`, + ); + }); }); }); diff --git a/packages/unplugin-typegpu/test/sourceMaps.test.ts b/packages/unplugin-typegpu/test/sourceMaps.test.ts new file mode 100644 index 0000000000..111aa862b3 --- /dev/null +++ b/packages/unplugin-typegpu/test/sourceMaps.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + type BabelTestPlugin, + babelTransform, + rollupTransform, + webpackTransform, +} from './transform.ts'; +import * as t from '@babel/types'; +import type { Plugin } from 'rollup'; +import * as parser from '@babel/parser'; +import traverse, { type TraverseOptions } from '@babel/traverse'; +import MagicString from 'magic-string'; +import { getBabelParserOptions, getLang } from 'ast-kit'; +import { createUnplugin, type UnpluginFactory } from 'unplugin'; + +// Both plugins inject a `console.log()` in the first line. +const babelPlugin: BabelTestPlugin = { + name: 'add-log', + visitor: { + Program(path) { + const logCall = t.expressionStatement( + t.callExpression(t.memberExpression(t.identifier('console'), t.identifier('log')), []), + ); + + path.unshiftContainer('body', [logCall]); + }, + }, +}; + +const unpluginFactory = (() => { + return { + name: 'add-log', + transform: { + handler(this, code: string, id: string) { + const functionVisitor: TraverseOptions<{ magicString: MagicString }> = { + Program(_, state) { + state.magicString.prependLeft(0, 'console.log()\n'); + }, + }; + + const ast = parser.parse( + code, + getBabelParserOptions(getLang(id), { + sourceType: 'module', + allowReturnOutsideFunction: true, + }), + ); + + const magicString = new MagicString(code); + const state = { magicString }; + traverse(ast, functionVisitor, undefined, state); + + return { + code: magicString.toString(), + map: magicString.generateMap({ + source: id, + includeContent: true, + hires: 'boundary', + }), + }; + }, + }, + }; +}) satisfies UnpluginFactory; +const unpluginPlugin = createUnplugin(unpluginFactory); +const rollupPlugin = unpluginPlugin.rollup() as Plugin; +const webpackPlugin = unpluginPlugin.webpack(); + +// The result includes many irrelevant comments, +// including the path that is different with each test execution. +function stripWebpackResult(res: string) { + return res.split('/* harmony export */ });\n').at(-1)?.split('/******/ })()').at(0); +} + +function stripAstBody(res: string | null | undefined) { + if (!res) { + return res; + } + return res + .split(/body: |"body":/) + .at(-1) + ?.split('},\n') + .at(0); +} + +describe('source maps', () => { + describe('assigns source maps metadata', () => { + const code = `\ + import { tgpu } from 'typegpu'; + + const external = { n: 1 } + + export const fn = (argument) => { + 'use gpu'; + const variable = 3; + return external.n + argument + variable; + };`; + + test('[BABEL]', () => { + const transformed = babelTransform(code, { unstable_sourceMaps: true }); + + expect(transformed).toMatchInlineSnapshot( + ` + "import { tgpu } from 'typegpu'; + const external = { + n: 1 + }; + export const fn = /*#__PURE__*/($ => (globalThis.__TYPEGPU_META__ ??= new WeakMap()).set($.f = argument => { + 'use gpu'; + + const variable = 3; + return __tsover_add(__tsover_add(external.n, argument), variable); + }, { + v: 2, + name: "fn", + ast: { + params: [{ + type: "i", + name: "argument" + }], + body: [-1, 5, 38, [0, [[-1, 7, 8, [13, [-1, 7, 14, [9, "variable"]], [-1, 7, 25, [5, "3"]]]], [-1, 8, 8, [10, [-1, 8, 15, [1, [-1, 8, 15, [1, [-1, 8, 15, [9, "external.n"]], "+", [-1, 8, 28, [9, "argument"]]]], "+", [-1, 8, 39, [9, "variable"]]]]]]]]] + }, + externals: { + "external.n": () => external.n + } + }) && $.f)({});" + `, + ); + }); + + test('[ROLLUP]', async () => { + const transformed = await rollupTransform(code, { unstable_sourceMaps: true }); + + expect(transformed).toMatchInlineSnapshot( + ` + "import 'typegpu'; + + const external = { n: 1 }; + + const fn = (/*#__PURE__*/($ => (globalThis.__TYPEGPU_META__ ??= new WeakMap()).set($.f = ((argument) => { + 'use gpu'; + const variable = 3; + return __tsover_add(__tsover_add(external.n, argument), variable); + }), { + v: 2, + name: "fn", + ast: {"params":[{"type":"i","name":"argument"}],"body":[-1,5,38,[0,[[-1,7,8,[13,[-1,7,14,[9,"variable"]],[-1,7,25,[5,"3"]]]],[-1,8,8,[10,[-1,8,15,[1,[-1,8,15,[1,[-1,8,15,[9,"external.n"]],"+",[-1,8,28,[9,"argument"]]]],"+",[-1,8,39,[9,"variable"]]]]]]]]]}, + externals: {"external.n":() => external.n} + }) && $.f)({})); + + export { fn }; + " + `, + ); + }); + }); + + describe('assigns source maps for object expressions and bools', () => { + const code = `\ + export const fn = () => { + 'use gpu'; + const a = 1; + const b = true; + const c = { p: 1, q: 1 }; + };`; + + test('[BABEL]', () => { + const transformed = babelTransform(code, { unstable_sourceMaps: true }); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(` + "[-1, 1, 30, [0, [[-1, 3, 8, [13, [-1, 3, 14, [9, "a"]], [-1, 3, 18, [5, "1"]]]], [-1, 4, 8, [13, [-1, 4, 14, [9, "b"]], [-1, 4, 18, [107, true]]]], [-1, 5, 8, [13, [-1, 5, 14, [9, "c"]], [-1, 5, 18, [104, { + p: [-1, 5, 23, [5, "1"]], + q: [-1, 5, 29, [5, "1"]] + }]]]]]]] + " + `); + }); + + test('[ROLLUP]', async () => { + const transformed = await rollupTransform(code, { unstable_sourceMaps: true }); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot( + `"[-1,1,30,[0,[[-1,3,8,[13,[-1,3,14,[9,"a"]],[-1,3,18,[5,"1"]]]],[-1,4,8,[13,[-1,4,14,[9,"b"]],[-1,4,18,[107,true]]]],[-1,5,8,[13,[-1,5,14,[9,"c"]],[-1,5,18,[104,{"p":[-1,5,23,[5,"1"]],"q":[-1,5,29,[5,"1"]]}]]]]]]]"`, + ); + }); + }); + + describe('assigns source maps for body-less functions', () => { + const code = `\ + import { tgpu, d } from 'typegpu'; + + export const fn = tgpu.fn([], d.u32)(() => 42)`; + + test('[BABEL]', () => { + const transformed = babelTransform(code, { unstable_sourceMaps: true }); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(` + "[0, [[10, [-1, 3, 49, [5, "42"]]]]] + " + `); + }); + + test('[ROLLUP]', async () => { + const transformed = await rollupTransform(code, { unstable_sourceMaps: true }); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(`"[0,[[10,[-1,3,49,[5,"42"]]]]]"`); + }); + }); + + describe('multiple plugins', () => { + const code = `\ + export const fn = () => { + 'use gpu'; + return 1; + };`; + + describe('retains original source maps when run second', () => { + test('[BABEL]', () => { + const transformed = babelTransform(code, { unstable_sourceMaps: true }, [babelPlugin]); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(` + "[-1, 1, 30, [0, [[-1, 3, 8, [10, [-1, 3, 15, [5, "1"]]]]]]] + " + `); + }); + + test('[ROLLUP]', async () => { + const transformed = await rollupTransform(code, { unstable_sourceMaps: true }, [ + rollupPlugin, + ]); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(`"[-1,1,30,[0,[[-1,3,8,[10,[-1,3,15,[5,"1"]]]]]]]"`); + }); + }); + + test('[WEBPACK] works when plugin does not expose `getCombinedSourcemap`', async () => { + const transformed = await webpackTransform(code, { unstable_sourceMaps: true }); + const stripped = stripAstBody(stripWebpackResult(transformed)); + + expect(stripped).toMatchInlineSnapshot(`"[-1,1,30,[0,[[-1,3,8,[10,[-1,3,15,[5,"1"]]]]]]]"`); + }); + + test('[WEBPACK] warns and falls back to node position when run second and plugin does not expose `getCombinedSourcemap`', async () => { + // The warn is module-scoped, so we need to reset the module before testing it. + vi.resetModules(); + const { webpackTransform } = await import('./transform.ts'); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const transformed = await webpackTransform(code, { unstable_sourceMaps: true }, [ + webpackPlugin, + ]); + const stripped = stripAstBody(stripWebpackResult(transformed)); + + expect(stripped).toMatchInlineSnapshot(`"[-1,2,30,[0,[[-1,4,8,[10,[-1,4,15,[5,"1"]]]]]]]"`); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` + [ + "[unplugin-typegpu] The current bundler does not provide combined source maps (getCombinedSourcemap). + If another plugin modifies the code, source maps may point to modified locations.", + ] + `); + }); + + test('[WEBPACK] warns only once', async () => { + vi.resetModules(); + const { webpackTransform } = await import('./transform.ts'); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await webpackTransform(code, { unstable_sourceMaps: true }, [webpackPlugin]); + await webpackTransform(code, { unstable_sourceMaps: true }, [webpackPlugin]); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + test('[WEBPACK] does not warn when source maps are disabled', async () => { + vi.resetModules(); + const { webpackTransform } = await import('./transform.ts'); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await webpackTransform(code, { unstable_sourceMaps: false }, [webpackPlugin]); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + describe('retains original source maps when multiple plugins run before', () => { + test('[BABEL]', () => { + const transformed = babelTransform( + code, + { unstable_sourceMaps: true }, + [babelPlugin, babelPlugin, babelPlugin], + [babelPlugin], + ); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(` + "[-1, 1, 30, [0, [[-1, 3, 8, [10, [-1, 3, 15, [5, "1"]]]]]]] + " + `); + }); + + test('[ROLLUP]', async () => { + const transformed = await rollupTransform( + code, + { unstable_sourceMaps: true }, + [rollupPlugin, rollupPlugin, rollupPlugin], + [rollupPlugin], + ); + const stripped = stripAstBody(transformed); + + expect(stripped).toMatchInlineSnapshot(`"[-1,1,30,[0,[[-1,3,8,[10,[-1,3,15,[5,"1"]]]]]]]"`); + }); + }); + }); +}); diff --git a/packages/unplugin-typegpu/test/transform.ts b/packages/unplugin-typegpu/test/transform.ts index 773253def3..3808fffc01 100644 --- a/packages/unplugin-typegpu/test/transform.ts +++ b/packages/unplugin-typegpu/test/transform.ts @@ -1,9 +1,16 @@ import Babel from '@babel/standalone'; import virtual from '@rollup/plugin-virtual'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { type Plugin, rollup } from 'rollup'; +import { type Configuration, webpack } from 'webpack'; import babelPlugin from '../src/babel.ts'; import type { Options } from '../src/core/common.ts'; import rollupPlugin from '../src/rollup.ts'; +import webpackPlugin from '../src/webpack.ts'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; const defaultOptions: Options = { include: [/\.m?[jt]sx?$/, /virtual:/], @@ -16,26 +23,78 @@ export type BabelTestPlugin = NonNullable[numb export const babelTransform = ( code: string, options?: Options, - additionalPlugins: BabelTestPlugin[] = [], + prePlugins: BabelTestPlugin[] = [], + postPlugins: BabelTestPlugin[] = [], ) => Babel.transform(code, { - plugins: [[babelPlugin, { ...defaultOptions, ...options }], ...additionalPlugins], + plugins: [...prePlugins, [babelPlugin, { ...defaultOptions, ...options }], ...postPlugins], parserOpts: { plugins: ['typescript'] }, }).code; export const rollupTransform = ( code: string, options?: Options, - additionalPlugins: Plugin[] = [], + prePlugins: Plugin[] = [], + postPlugins: Plugin[] = [], ) => rollup({ input: 'code', plugins: [ virtual({ code }), + ...prePlugins, rollupPlugin({ ...defaultOptions, ...options }), - ...additionalPlugins, + ...postPlugins, ], external: ['typegpu', /^typegpu\/.*$/], }) .then((build) => build.generate({})) .then((generated) => generated.output[0].code); + +export type WebpackTestPlugin = NonNullable[number]; + +async function createTempDir() { + const ostmpdir = os.tmpdir(); + const tmpdir = path.join(ostmpdir, 'unplugin-typegpu-test-'); + return await fs.mkdtemp(tmpdir); +} + +export async function webpackTransform( + code: string, + options?: Options, + prePlugins: WebpackTestPlugin[] = [], + postPlugins: WebpackTestPlugin[] = [], +) { + const dir = await createTempDir(); + try { + const input = join(dir, 'input.js'); + await writeFile(input, code); + + await new Promise((resolve, reject) => { + webpack( + { + entry: input, + mode: 'development', + devtool: 'source-map', + output: { path: join(dir, 'dist'), filename: 'output.js' }, + plugins: [ + ...prePlugins, + webpackPlugin({ ...defaultOptions, ...options }), + ...postPlugins, + ], + }, + (err, stats) => { + if (err || stats?.hasErrors()) { + console.error(stats?.toString()); + reject(err || new Error('Webpack bundling failed')); + } else { + resolve(); + } + }, + ); + }); + + return await readFile(join(dir, 'dist', 'output.js'), 'utf-8'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc72149561..f721b51181 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,7 +166,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.4.3 + version: 1.4.4 apps/infra-benchmarks: devDependencies: @@ -993,7 +993,6 @@ importers: version: link:../unplugin-typegpu publishDirectory: dist - packages/typegpu-testing-utility: dependencies: tinyest: @@ -1071,6 +1070,9 @@ importers: '@jridgewell/remapping': specifier: ^2.3.5 version: 2.3.5 + '@jridgewell/trace-mapping': + specifier: ^0.3.31 + version: 0.3.31 ast-kit: specifier: ^2.2.0 version: 2.2.0 @@ -1129,6 +1131,9 @@ importers: typescript: specifier: npm:tsover@^6.0.2 version: tsover@6.0.2 + webpack: + specifier: ^5.104.1 + version: 5.104.1 publishDirectory: dist packages: @@ -4591,6 +4596,9 @@ packages: '@types/bun@1.4.3': resolution: {integrity: sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw==} + '@types/bun@1.4.4': + resolution: {integrity: sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -5285,6 +5293,9 @@ packages: bun-types@1.4.3: resolution: {integrity: sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg==} + bun-types@1.4.4: + resolution: {integrity: sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ==} + bun@1.3.10: resolution: {integrity: sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==} cpu: [arm64, x64] @@ -12848,6 +12859,10 @@ snapshots: dependencies: bun-types: 1.4.3 + '@types/bun@1.4.4': + dependencies: + bun-types: 1.4.4 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -13783,6 +13798,11 @@ snapshots: '@types/node': 24.10.0 undici-types: 7.16.0 + bun-types@1.4.4: + dependencies: + '@types/node': 24.10.0 + undici-types: 7.16.0 + bun@1.3.10: optionalDependencies: '@oven/bun-darwin-aarch64': 1.3.10 @@ -18338,6 +18358,15 @@ snapshots: optionalDependencies: esbuild: 0.25.12 + terser-webpack-plugin@5.3.16(webpack@5.104.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.51.2 + webpack: 5.104.1 + terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 @@ -19012,6 +19041,38 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.104.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.19.0 + acorn-import-phases: 1.0.4(acorn@8.19.0) + browserslist: 4.29.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.4 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(webpack@5.104.1) + watchpack: 2.5.0 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.104.1(esbuild@0.25.12)(webpack-cli@6.0.1): dependencies: '@types/eslint-scope': 3.7.7