Skip to content

Commit c68f86a

Browse files
feat: Lint plugin POC (#2097)
1 parent 870170b commit c68f86a

13 files changed

Lines changed: 1038 additions & 11 deletions

packages/eslint-plugin/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div align="center">
2+
3+
# eslint-plugin-typegpu
4+
5+
🚧 **Under Construction** 🚧 -
6+
[GitHub](https://github.com/software-mansion/TypeGPU/tree/main/packages/eslint-plugin)
7+
8+
</div>

packages/eslint-plugin/deno.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"exclude": ["."],
3+
"fmt": {
4+
"exclude": ["!."],
5+
"singleQuote": true
6+
}
7+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "eslint-plugin-typegpu",
3+
"version": "0.9.0",
4+
"description": "An eslint plugin containing custom rules for TypeGPU.",
5+
"type": "module",
6+
"exports": {
7+
".": {
8+
"types": "./dist/index.d.ts",
9+
"import": "./dist/index.js"
10+
}
11+
},
12+
"scripts": {
13+
"build": "tsup",
14+
"test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
15+
"test": "vitest"
16+
},
17+
"sideEffects": false,
18+
"license": "MIT",
19+
"packageManager": "pnpm@10.15.1",
20+
"dependencies": {
21+
"@typescript-eslint/utils": "^8.53.0"
22+
},
23+
"peerDependencies": {
24+
"eslint": "^9.0.0"
25+
},
26+
"devDependencies": {
27+
"@types/node": "^25.0.10",
28+
"@typescript-eslint/rule-tester": "^8.53.1",
29+
"eslint": "^9.39.2",
30+
"typescript": "^5.9.3",
31+
"vitest": "^4.0.17"
32+
}
33+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { TSESLint } from '@typescript-eslint/utils';
2+
import { integerDivision } from './rules/integerDivision.ts';
3+
4+
export const rules = {
5+
'integer-division': integerDivision,
6+
} as const;
7+
8+
type Rules = Record<
9+
`typegpu/${keyof typeof rules}`,
10+
TSESLint.FlatConfig.RuleEntry
11+
>;
12+
13+
export const recommendedRules: Rules = {
14+
'typegpu/integer-division': 'warn',
15+
};
16+
17+
export const allRules: Rules = {
18+
'typegpu/integer-division': 'error',
19+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pkg from '../package.json';
2+
import type { TSESLint } from '@typescript-eslint/utils';
3+
import { allRules, recommendedRules, rules } from './configs.ts';
4+
5+
const pluginBase: TSESLint.FlatConfig.Plugin = {
6+
meta: {
7+
name: pkg.name,
8+
version: pkg.version,
9+
},
10+
rules,
11+
};
12+
13+
const recommended: TSESLint.FlatConfig.Config = {
14+
name: 'typegpu/recommended',
15+
plugins: { typegpu: pluginBase },
16+
rules: recommendedRules,
17+
};
18+
19+
const all: TSESLint.FlatConfig.Config = {
20+
name: 'typegpu/all',
21+
plugins: { typegpu: pluginBase },
22+
rules: allRules,
23+
};
24+
25+
const plugin: TSESLint.FlatConfig.Plugin = {
26+
...pluginBase,
27+
configs: {
28+
recommended,
29+
all,
30+
},
31+
};
32+
33+
export default plugin;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ESLintUtils } from '@typescript-eslint/utils';
2+
3+
export const createRule = ESLintUtils.RuleCreator(
4+
// TODO: docs for lint rules
5+
() => `https://docs.swmansion.com/TypeGPU/getting-started/`,
6+
);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { TSESTree } from '@typescript-eslint/utils';
2+
import { createRule } from '../ruleCreator.ts';
3+
4+
// TODO: detect `std.div(d.u32(1), d.u32(2))`
5+
export const integerDivision = createRule({
6+
name: 'integer-division',
7+
meta: {
8+
type: 'suggestion',
9+
docs: { description: `Avoid dividing numbers wrapped in 'u32' and 'i32'.` },
10+
messages: {
11+
intDiv:
12+
"'{{snippet}}' might result in floating point values. To perform integer division, wrap the result in 'd.u32' or 'd.i32' instead.",
13+
},
14+
schema: [],
15+
},
16+
defaultOptions: [],
17+
18+
create(context) {
19+
return {
20+
BinaryExpression(node) {
21+
if (node.operator !== '/') {
22+
return;
23+
}
24+
25+
if (node.parent?.type === 'CallExpression' && isIntCast(node.parent)) {
26+
return;
27+
}
28+
29+
if (isIntCast(node.left) || isIntCast(node.right)) {
30+
context.report({
31+
node,
32+
messageId: 'intDiv',
33+
data: { snippet: context.sourceCode.getText(node) },
34+
});
35+
}
36+
},
37+
};
38+
},
39+
});
40+
41+
/**
42+
* Checks if a node is a call expression to an integer cast function (i32 or u32).
43+
*
44+
* @example
45+
* // for simplicity, using code snippets instead of ASTs
46+
* isIntCasts('d.u32()'); // true
47+
* isIntCasts('i32()'); // true
48+
* isIntCasts('f32()'); // false
49+
*/
50+
function isIntCast(node: TSESTree.Expression): boolean {
51+
if (node.type !== 'CallExpression') {
52+
return false;
53+
}
54+
55+
let callee: TSESTree.Node = node.callee;
56+
while (callee.type === 'MemberExpression') {
57+
callee = callee.property;
58+
}
59+
60+
return callee.type === 'Identifier' && ['i32', 'u32'].includes(callee.name);
61+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe } from 'vitest';
2+
import { integerDivision } from '../src/rules/integerDivision.ts';
3+
import { ruleTester } from './ruleTester.ts';
4+
5+
describe('integerDivision', () => {
6+
ruleTester.run('integerDivision', integerDivision, {
7+
valid: [
8+
'1 / 2',
9+
'd.u32(d.u32(1) / d.u32(2))',
10+
],
11+
invalid: [
12+
{
13+
code: 'd.u32(1) / 2',
14+
errors: [
15+
{ messageId: 'intDiv', data: { snippet: 'd.u32(1) / 2' } },
16+
],
17+
},
18+
{
19+
code: '1 / d.u32(2)',
20+
errors: [
21+
{ messageId: 'intDiv', data: { snippet: '1 / d.u32(2)' } },
22+
],
23+
},
24+
{
25+
code: 'd.u32(1) / d.u32(2)',
26+
errors: [
27+
{ messageId: 'intDiv', data: { snippet: 'd.u32(1) / d.u32(2)' } },
28+
],
29+
},
30+
{
31+
code: 'd.i32(1) / d.i32(2)',
32+
errors: [
33+
{ messageId: 'intDiv', data: { snippet: 'd.i32(1) / d.i32(2)' } },
34+
],
35+
},
36+
{
37+
code: 'd.u32(1) / d.i32(2)',
38+
errors: [
39+
{ messageId: 'intDiv', data: { snippet: 'd.u32(1) / d.i32(2)' } },
40+
],
41+
},
42+
{
43+
code: 'u32(1) / u32(2)',
44+
errors: [
45+
{ messageId: 'intDiv', data: { snippet: 'u32(1) / u32(2)' } },
46+
],
47+
},
48+
{
49+
code: 'd.u32(1) / d.u32(2) / d.u32(3)',
50+
errors: [
51+
{
52+
messageId: 'intDiv',
53+
data: { snippet: 'd.u32(1) / d.u32(2) / d.u32(3)' },
54+
},
55+
{ messageId: 'intDiv', data: { snippet: 'd.u32(1) / d.u32(2)' } },
56+
],
57+
},
58+
],
59+
});
60+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester';
2+
import { afterAll, describe, it } from 'vitest';
3+
4+
// RuleTester relies on global hooks for tests.
5+
// Vitest doesn't make the hooks available globally, so we need to bind them.
6+
RuleTester.describe = describe;
7+
RuleTester.it = it;
8+
RuleTester.afterAll = afterAll;
9+
10+
export const ruleTester = new RuleTester();
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["src/**/*", "tests/**/*"],
7+
"exclude": ["node_modules", "dist"]
8+
}

0 commit comments

Comments
 (0)