Skip to content

Commit f071585

Browse files
feat: RuleEnhancer & unwrapped POJOs rule (#2127)
1 parent 70f0c6e commit f071585

5 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import type { TSESLint } from '@typescript-eslint/utils';
22
import { integerDivision } from './rules/integerDivision.ts';
3+
import { unwrappedPojos } from './rules/unwrappedPojos.ts';
34

45
export const rules = {
56
'integer-division': integerDivision,
7+
'unwrapped-pojo': unwrappedPojos,
68
} as const;
79

810
type Rules = Record<`typegpu/${keyof typeof rules}`, TSESLint.FlatConfig.RuleEntry>;
911

1012
export const recommendedRules: Rules = {
1113
'typegpu/integer-division': 'warn',
14+
'typegpu/unwrapped-pojo': 'warn',
1215
};
1316

1417
export const allRules: Rules = {
1518
'typegpu/integer-division': 'error',
19+
'typegpu/unwrapped-pojo': 'error',
1620
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { RuleContext, RuleListener } from '@typescript-eslint/utils/ts-eslint';
2+
3+
export type RuleEnhancer<TState> = (context: RuleContext<string, unknown[]>) => {
4+
visitors: RuleListener;
5+
state: TState;
6+
};
7+
8+
type State<TMap extends Record<string, RuleEnhancer<unknown>>> = {
9+
[K in keyof TMap]: TMap[K] extends RuleEnhancer<infer S> ? S : never;
10+
};
11+
12+
/**
13+
* Allows enhancing rule code with additional context provided by RuleEnhancers (reusable node visitors collecting data).
14+
* @param enhancers a record of RuleEnhancers
15+
* @param rule a visitor with an additional `state` argument that allows access to the enhancers' data
16+
* @returns a resulting `(context: Context) => RuleListener` function
17+
*
18+
* @example
19+
* // inside of `createRule`
20+
* create: enhanceRule({ metadata: metadataTrackingEnhancer }, (context, state) => {
21+
* const { metadata } = state;
22+
*
23+
* return {
24+
* ObjectExpression(node) {
25+
* if (metadata.shouldReport()) {
26+
* context.report({ node, messageId: 'error' });
27+
* }
28+
* },
29+
* };
30+
*/
31+
export function enhanceRule<
32+
TMap extends Record<string, RuleEnhancer<unknown>>,
33+
Context extends RuleContext<string, unknown[]>,
34+
>(enhancers: TMap, rule: (context: Context, state: State<TMap>) => RuleListener) {
35+
return (context: Context) => {
36+
const enhancerVisitors: RuleListener[] = [];
37+
const combinedState: Record<string, unknown> = {};
38+
39+
for (const [key, enhancer] of Object.entries(enhancers)) {
40+
const initializedEnhancer = enhancer(context);
41+
enhancerVisitors.push(initializedEnhancer.visitors);
42+
combinedState[key] = initializedEnhancer.state;
43+
}
44+
45+
const initializedRule = rule(context, combinedState as State<TMap>);
46+
47+
return mergeVisitors([...enhancerVisitors, initializedRule]);
48+
};
49+
}
50+
51+
/**
52+
* Merges all passed visitors into one visitor.
53+
* Retains visitor order:
54+
* - on node enter, visitors are called in `visitorsList` order,
55+
* - on node exit, visitors are called in reversed order.
56+
*/
57+
function mergeVisitors(visitors: RuleListener[]): RuleListener {
58+
const merged: RuleListener = {};
59+
60+
const allKeys = new Set(visitors.flatMap((v) => Object.keys(v)));
61+
62+
for (const key of allKeys) {
63+
const listeners = visitors.map((v) => v[key]).filter((fn) => fn !== undefined);
64+
65+
if (listeners.length === 0) {
66+
continue;
67+
}
68+
69+
// Reverse order if node is an exit node
70+
if (key.endsWith(':exit')) {
71+
listeners.reverse();
72+
}
73+
74+
merged[key] = (...args: unknown[]) => {
75+
listeners.forEach((fn) => (fn as (...args: unknown[]) => void)(...args));
76+
};
77+
}
78+
79+
return merged;
80+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { TSESTree } from '@typescript-eslint/utils';
2+
import type { RuleListener } from '@typescript-eslint/utils/ts-eslint';
3+
import type { RuleEnhancer } from '../enhanceRule.ts';
4+
5+
export type DirectiveData = {
6+
insideUseGpu: () => boolean;
7+
};
8+
9+
/**
10+
* A RuleEnhancer that tracks whether the current node is inside a 'use gpu' function.
11+
*
12+
* @privateRemarks
13+
* Should the need arise, the API could be updated to expose:
14+
* - a list of directives of the current function,
15+
* - directives of other visited functions,
16+
* - top level directives.
17+
*/
18+
export const directiveTracking: RuleEnhancer<DirectiveData> = () => {
19+
const stack: string[][] = [];
20+
21+
const visitors: RuleListener = {
22+
FunctionDeclaration(node) {
23+
stack.push(getDirectives(node));
24+
},
25+
FunctionExpression(node) {
26+
stack.push(getDirectives(node));
27+
},
28+
ArrowFunctionExpression(node) {
29+
stack.push(getDirectives(node));
30+
},
31+
32+
'FunctionDeclaration:exit'() {
33+
stack.pop();
34+
},
35+
'FunctionExpression:exit'() {
36+
stack.pop();
37+
},
38+
'ArrowFunctionExpression:exit'() {
39+
stack.pop();
40+
},
41+
};
42+
43+
return {
44+
visitors,
45+
state: { insideUseGpu: () => (stack.at(-1) ?? []).includes('use gpu') },
46+
};
47+
};
48+
49+
function getDirectives(
50+
node:
51+
| TSESTree.FunctionDeclaration
52+
| TSESTree.FunctionExpression
53+
| TSESTree.ArrowFunctionExpression,
54+
): string[] {
55+
const body = node.body;
56+
if (body.type !== 'BlockStatement') {
57+
return [];
58+
}
59+
60+
const directives: string[] = [];
61+
for (const statement of body.body) {
62+
if (statement.type === 'ExpressionStatement' && statement.directive) {
63+
directives.push(statement.directive);
64+
} else {
65+
break;
66+
}
67+
}
68+
69+
return directives;
70+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { enhanceRule } from '../enhanceRule.ts';
2+
import { directiveTracking } from '../enhancers/directiveTracking.ts';
3+
import { createRule } from '../ruleCreator.ts';
4+
5+
export const unwrappedPojos = createRule({
6+
name: 'unwrapped-pojo',
7+
meta: {
8+
type: 'problem',
9+
docs: {
10+
description: `Wrap Plain Old JavaScript Objects with schemas.`,
11+
},
12+
messages: {
13+
unwrappedPojo:
14+
'{{snippet}} is a POJO that is not wrapped in a schema. To allow WGSL resolution, wrap it in a schema call. You only need to wrap the outermost object.',
15+
},
16+
schema: [],
17+
},
18+
defaultOptions: [],
19+
20+
create: enhanceRule({ directives: directiveTracking }, (context, state) => {
21+
const { directives } = state;
22+
23+
return {
24+
ObjectExpression(node) {
25+
if (!directives.insideUseGpu()) {
26+
return;
27+
}
28+
if (node.parent?.type === 'Property') {
29+
// a part of a bigger struct
30+
return;
31+
}
32+
if (node.parent?.type === 'CallExpression') {
33+
// wrapped in a schema call
34+
return;
35+
}
36+
if (node.parent?.type === 'ReturnStatement') {
37+
// likely inferred (shelled fn or shell-less entry) so we cannot report
38+
return;
39+
}
40+
context.report({
41+
node,
42+
messageId: 'unwrappedPojo',
43+
data: { snippet: context.sourceCode.getText(node) },
44+
});
45+
},
46+
};
47+
}),
48+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe } from 'vitest';
2+
import { ruleTester } from './ruleTester.ts';
3+
import { unwrappedPojos } from '../src/rules/unwrappedPojos.ts';
4+
5+
describe('unwrappedPojos', () => {
6+
ruleTester.run('unwrappedPojos', unwrappedPojos, {
7+
valid: [
8+
// correctly wrapped
9+
"function func() { 'use gpu'; const wrapped = Schema({ a: 1 }); }",
10+
"const func = function() { 'use gpu'; const wrapped = Schema({ a: 1 }); }",
11+
"() => { 'use gpu'; const wrapped = Schema({ a: 1 }); }",
12+
13+
// not inside 'use gpu' function
14+
'const pojo = { a: 1 };',
15+
'function func() { const unwrapped = { a: 1 }; }',
16+
'const func = function () { const unwrapped = { a: 1 }; }',
17+
'() => { const unwrapped = { a: 1 }; }',
18+
'function func() { return { a: 1 }; }',
19+
'const func = function () { return { a: 1 }; }',
20+
'() => { return { a: 1 }; }',
21+
22+
// return from 'use gpu' function
23+
"function func() { 'use gpu'; return { a: 1 }; }",
24+
"const func = function() { 'use gpu'; return { a: 1 }; }",
25+
"() => { 'use gpu'; return { a: 1 }; }",
26+
"() => { 'use gpu'; return { a: { b: 1 } }; }",
27+
],
28+
invalid: [
29+
{
30+
code: "function func() { 'use gpu'; const unwrapped = { a: 1 }; }",
31+
errors: [
32+
{
33+
messageId: 'unwrappedPojo',
34+
data: { snippet: '{ a: 1 }' },
35+
},
36+
],
37+
},
38+
{
39+
code: "const func = function() { 'use gpu'; const unwrapped = { a: 1 }; }",
40+
errors: [
41+
{
42+
messageId: 'unwrappedPojo',
43+
data: { snippet: '{ a: 1 }' },
44+
},
45+
],
46+
},
47+
{
48+
code: "() => { 'use gpu'; const unwrapped = { a: 1 }; }",
49+
errors: [
50+
{
51+
messageId: 'unwrappedPojo',
52+
data: { snippet: '{ a: 1 }' },
53+
},
54+
],
55+
},
56+
{
57+
code: "function func() { 'unknown directive'; 'use gpu'; const unwrapped = { a: 1 }; }",
58+
errors: [
59+
{
60+
messageId: 'unwrappedPojo',
61+
data: { snippet: '{ a: 1 }' },
62+
},
63+
],
64+
},
65+
{
66+
code: "() => { 'use gpu'; const unwrapped = { a: { b: 1 } }; }",
67+
errors: [
68+
{
69+
messageId: 'unwrappedPojo',
70+
data: { snippet: '{ a: { b: 1 } }' },
71+
},
72+
],
73+
},
74+
],
75+
});
76+
});

0 commit comments

Comments
 (0)