Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/config-typescript/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
Expand Down
1 change: 1 addition & 0 deletions packages/matchers/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/generated.ts
66 changes: 66 additions & 0 deletions packages/matchers/benchmark/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as t from '@babel/types';
import * as cm from '@codemod/matchers';
import * as b from 'benny';
import * as m from '../src/index';

const webcrackMatcher = m.compile(
m.binaryExpression(
m.any,
m.numericLiteral(m.capture('left')),
m.binaryExpression(
m.any,
m.numericLiteral(m.capture('middle')),
m.numericLiteral(m.capture('right')),
),
),
);
console.log(webcrackMatcher.toString());

const left = cm.capture(cm.anyNumber());
const middle = cm.capture(cm.anyNumber());
const right = cm.capture(cm.anyNumber());
const codemodMatcher = cm.binaryExpression(
cm.anything(),
cm.numericLiteral(left),
cm.binaryExpression(
cm.anything(),
cm.numericLiteral(middle),
cm.numericLiteral(right),
),
);

const binExp = t.binaryExpression(
'+',
t.numericLiteral(1),
t.binaryExpression('+', t.numericLiteral(2), t.numericLiteral(3)),
);
const binExp2 = t.binaryExpression(
'+',
t.numericLiteral(1),
t.binaryExpression('+', t.stringLiteral('x'), t.numericLiteral(3)),
);

b.suite(
'Matcher Performance',

b.add('@webcrack/matchers', () => {
const captures = webcrackMatcher(binExp);
if (captures) {
captures.left === captures.right;
}
const captures2 = webcrackMatcher(binExp2);
if (captures2) {
captures2.left === captures2.right;
}
}),
b.add('@codemod/matchers', () => {
if (codemodMatcher.match(binExp)) {
left.current === right.current;
}
if (codemodMatcher.match(binExp2)) {
left.current === right.current;
}
}),
b.cycle(),
b.complete(),
);
11 changes: 11 additions & 0 deletions packages/matchers/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import config from '@webcrack/eslint-config';

/**
* @type {import('eslint').Linter.Config[]}
*/
export default [
...config,
{
ignores: ['src/generated.ts'],
},
];
32 changes: 32 additions & 0 deletions packages/matchers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@webcrack/matchers",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"build": "tsx scripts/build.ts",
"bench": "tsx benchmark/test.js",
"lint": "eslint src scripts",
"lint:fix": "eslint src scripts --fix",
"format:check": "prettier --check \"{src,scripts}/**/*.ts\"",
"format": "prettier --write \"{src,scripts}**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "vitest --no-isolate"
},
"dependencies": {
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
},
"devDependencies": {
"@babel/traverse": "^7.27.0",
"@codemod/matchers": "^1.7.1",
"@types/babel__traverse": "^7.20.7",
"@types/node": "^22.13.16",
"@webcrack/eslint-config": "workspace:*",
"@webcrack/typescript-config": "workspace:*",
"benny": "^3.7.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
136 changes: 136 additions & 0 deletions packages/matchers/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as t from '@babel/types';
import { createWriteStream } from 'node:fs';

type Validator = Required<t.FieldOptions>['validate'];

const out = createWriteStream(new URL('../src/generated.ts', import.meta.url));

out.write(`// This file is generated by scripts/build.ts. Do not edit manually.
import type * as t from '@babel/types';
import type { NodeSchema, Schema } from './types.js';

`);

for (const type of Object.keys(t.BUILDER_KEYS).sort()) {
const exportedName = toFunctionName(type);
const functionName = t.toBindingIdentifierName(exportedName);

if (exportedName === functionName) {
out.write(`export `);
} else {
out.write(`export { ${functionName} as ${exportedName} };\n`);
}

out.write(`function ${functionName}(${generateBuilderArgs(type).join(', ')}): NodeSchema<t.${type}> {
return { type: '${type}', ${generateBuilderProperties(type).join(', ')} };
}
`);
}

out.close();

function toFunctionName(typeName: string): string {
return typeName.replace(/^(TS|JSX|[A-Z])/, (match) => match.toLowerCase());
}

function generateBuilderArgs(type: string): string[] {
const fields = t.NODE_FIELDS[type];
const fieldNames = sortFieldNames(Object.keys(t.NODE_FIELDS[type]), type);
const builderNames = t.BUILDER_KEYS[type];

const args: string[] = [];

fieldNames.forEach((fieldName) => {
const field = fields[fieldName];
let typeAnnotation = stringifyValidator(field.validate, 't.');

if (isNullable(field) && !hasDefault(field)) {
typeAnnotation += ' | null';
}

if (builderNames.includes(fieldName)) {
const bindingIdentifierName = t.toBindingIdentifierName(fieldName);
const arg = `${bindingIdentifierName}?: Schema<${typeAnnotation}>`;
args.push(arg);
}
});

return args;
}

function generateBuilderProperties(type: string): string[] {
const builderKeys = sortFieldNames(t.BUILDER_KEYS[type], type);
return builderKeys.map((key) => {
const argName = t.toBindingIdentifierName(key);
return argName === key ? key : `${key}: ${argName}`;
});
}

function hasDefault(field: t.FieldOptions): boolean {
return field.default != null;
}

function isNullable(field: t.FieldOptions): boolean {
return field.optional || hasDefault(field);
}

function sortFieldNames(fields: string[], type: string): string[] {
return fields.sort((fieldA, fieldB) => {
const indexA = t.BUILDER_KEYS[type].indexOf(fieldA);
const indexB = t.BUILDER_KEYS[type].indexOf(fieldB);
if (indexA === indexB) return fieldA < fieldB ? -1 : 1;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}

export default function stringifyValidator(
validator: Validator | undefined,
nodePrefix: string,
): string {
if (validator === undefined) {
return 'any';
}

if ('each' in validator) {
return `Array<${stringifyValidator(validator.each, nodePrefix)}>`;
}

if ('chainOf' in validator) {
const ret = stringifyValidator(validator.chainOf[1], nodePrefix);
return Array.isArray(ret) && ret.length === 1 && ret[0] === 'any'
? stringifyValidator(validator.chainOf[0], nodePrefix)
: ret;
}

if ('oneOf' in validator) {
return validator.oneOf.map((v) => JSON.stringify(v)).join(' | ');
}

if ('oneOfNodeTypes' in validator) {
return validator.oneOfNodeTypes.map((_) => nodePrefix + _).join(' | ');
}

if ('oneOfNodeOrValueTypes' in validator) {
return validator.oneOfNodeOrValueTypes
.map((_) => {
return isValueType(_) ? _ : nodePrefix + _;
})
.join(' | ');
}

if ('type' in validator) {
return validator.type;
}

return 'any';
}

/**
* Heuristic to decide whether or not the given type is a value type (eg. "null")
* or a Node type (eg. "Expression").
*/
export function isValueType(type: string) {
return type.charAt(0).toLowerCase() === type.charAt(0);
}
Loading