From 11122f3145b6797760b21695997e89ed00d6b3a7 Mon Sep 17 00:00:00 2001 From: xpyctumo <30053565+xpyctumo@users.noreply.github.com> Date: Fri, 31 Jan 2025 19:04:42 +0300 Subject: [PATCH] test: parser/printer fuzz tests for expressions (#1248) --- .eslintignore | 1 + dev-docs/CONTRIBUTING.md | 20 ++ jest.config.js | 3 +- jest.globalSetup.js | 8 + jest.setup.js | 54 +++++- package.json | 7 +- spell/cspell-list.txt | 1 + src/ast/fuzz.spec.ts | 34 ++++ src/ast/random-ast.ts | 22 +++ src/ast/random.infra.ts | 397 +++++++++++++++++++++++++++++++++++++++ yarn.lock | 19 +- 11 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 jest.globalSetup.js create mode 100644 src/ast/fuzz.spec.ts create mode 100644 src/ast/random-ast.ts create mode 100644 src/ast/random.infra.ts diff --git a/.eslintignore b/.eslintignore index 888ea4579..e6691d775 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,6 +9,7 @@ src/func/funcfiftlib.js **/grammar.ohm*.js src/grammar/next/grammar.ts jest.setup.js +jest.globalSetup.js jest.teardown.js /docs version.build.ts diff --git a/dev-docs/CONTRIBUTING.md b/dev-docs/CONTRIBUTING.md index 721303f54..02b4be355 100644 --- a/dev-docs/CONTRIBUTING.md +++ b/dev-docs/CONTRIBUTING.md @@ -210,3 +210,23 @@ The project contains special TypeScript files with the `.build.ts` extension tha A typical example is [test/contracts.build.ts](https://github.com/tact-lang/tact/blob/132fe4ad7f030671d28740313b9d573fd8829684/src/test/contracts.build.ts) which builds contract tests. When adding new build or test scripts, make sure to use the `.build.ts` extension to keep them separate from the main compiler code. + +## Random AST Expression Generation + +To generate and inspect random Abstract Syntax Trees (ASTs), you can use the `yarn random-ast` command. This command generates a specified number of random Abstract Syntax Trees (ASTs) and pretty-prints them. + +Note: At the moment only Ast Expression will be generated + +### Usage + +`yarn random-ast ` + +Where `` is the number of random expressions to generate. + +### Example + +`yarn random-ast 42` + +This will produce 42 random expressions and pretty-print them in the terminal. + +The implementation can be found in [random-ast.ts](../src/ast/random-ast.ts). diff --git a/jest.config.js b/jest.config.js index 791d84af8..a5f5c40e8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,8 @@ module.exports = { testEnvironment: "node", testPathIgnorePatterns: ["/node_modules/", "/dist/"], maxWorkers: "8", - globalSetup: "./jest.setup.js", + globalSetup: "./jest.globalSetup.js", + setupFiles: ["./jest.setup.js"], globalTeardown: "./jest.teardown.js", snapshotSerializers: ["@tact-lang/ton-jest/serializers"], }; diff --git a/jest.globalSetup.js b/jest.globalSetup.js new file mode 100644 index 000000000..454fcbf08 --- /dev/null +++ b/jest.globalSetup.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const coverage = require("@tact-lang/coverage"); + +module.exports = async () => { + if (process.env.COVERAGE === "true") { + coverage.beginCoverage(); + } +}; diff --git a/jest.setup.js b/jest.setup.js index 40e3d863e..67a74f59a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,7 +1,51 @@ -const coverage = require("@tact-lang/coverage"); +const fc = require("fast-check"); -module.exports = async () => { - if (process.env.COVERAGE === "true") { - coverage.beginCoverage(); +function sanitizeObject( + obj, + options = { + excludeKeys: [], + valueTransformers: {}, + }, +) { + const { excludeKeys, valueTransformers } = options; + + if (Array.isArray(obj)) { + return obj.map((item) => sanitizeObject(item, options)); + } else if (obj !== null && typeof obj === "object") { + const newObj = {}; + for (const [key, value] of Object.entries(obj)) { + if (!excludeKeys.includes(key)) { + const transformer = valueTransformers[key]; + newObj[key] = transformer + ? transformer(value) + : sanitizeObject(value, options); + } + } + return newObj; } -}; + return obj; +} + +fc.configureGlobal({ + reporter: (log) => { + if (log.failed) { + const sanitizedCounterexample = sanitizeObject(log.counterexample, { + excludeKeys: ["id", "loc"], + valueTransformers: { + value: (val) => + typeof val === "bigint" ? val.toString() : val, + }, + }); + + const errorMessage = ` + Property failed after ${log.numRuns} tests + Seed: ${log.seed} + Path: ${log.counterexamplePath} + Counterexample: ${JSON.stringify(sanitizedCounterexample, null, 0)} + Errors: ${log.error ? log.error : "Unknown error"} + `; + + throw new Error(errorMessage); + } + }, +}); diff --git a/package.json b/package.json index 4b93d19c1..fca134d0c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "prepack": "pinst --disable", "postpack": "pinst --enable", "next-version": "ts-node version.build.ts", + "random-ast": "ts-node ./src/ast/random-ast.ts", "top10": "find . -type f -exec du -h {} + | sort -rh | head -n 10" }, "files": [ @@ -55,7 +56,8 @@ "!**/*.build.*", "!**/*.spec.js", "!**/*.spec.d.ts", - "!**/*.tsbuildinfo" + "!**/*.tsbuildinfo", + "!**/*.infra.ts" ], "main": "./dist/index.js", "bin": { @@ -86,6 +88,7 @@ "@ton/sandbox": "^0.24.0", "@ton/test-utils": "^0.5.0", "@tonstudio/pgen": "^0.0.1", + "@types/diff": "^7.0.0", "@types/glob": "^8.1.0", "@types/jest": "^29.5.12", "@types/json-bigint": "^1.0.4", @@ -96,7 +99,9 @@ "chalk": "4.1.2", "cross-env": "^7.0.3", "cspell": "^8.8.3", + "diff": "^7.0.0", "eslint": "^8.57.0", + "fast-check": "^3.23.2", "glob": "^8.1.0", "husky": "^9.1.5", "jest": "^29.3.1", diff --git a/spell/cspell-list.txt b/spell/cspell-list.txt index a6c69b4d7..7096cdaf3 100644 --- a/spell/cspell-list.txt +++ b/spell/cspell-list.txt @@ -203,3 +203,4 @@ workchains xffff xtwitter привет +letrec \ No newline at end of file diff --git a/src/ast/fuzz.spec.ts b/src/ast/fuzz.spec.ts new file mode 100644 index 000000000..a18c34452 --- /dev/null +++ b/src/ast/fuzz.spec.ts @@ -0,0 +1,34 @@ +import fc from "fast-check"; +import { getParser } from "../grammar"; +import { eqExpressions, getAstFactory } from "../ast/ast-helpers"; +import { diffAstObjects, randomAstExpression } from "./random.infra"; +import { prettyPrint } from "./ast-printer"; + +describe("Pretty Print Expressions", () => { + const maxDepth = 4; + const parser = getParser(getAstFactory(), "new"); + + it(`should parse AstExpression`, () => { + fc.assert( + fc.property(randomAstExpression(maxDepth), (generatedAst) => { + const prettyBefore = prettyPrint(generatedAst); + + const parsedAst = parser.parseExpression(prettyBefore); + const prettyAfter = prettyPrint(parsedAst); + + expect(prettyBefore).toBe(prettyAfter); + const actual = eqExpressions(generatedAst, parsedAst); + if (!actual) { + diffAstObjects( + generatedAst, + parsedAst, + prettyBefore, + prettyAfter, + ); + } + expect(actual).toBe(true); + }), + { seed: 1, numRuns: 5000 }, + ); + }); +}); diff --git a/src/ast/random-ast.ts b/src/ast/random-ast.ts new file mode 100644 index 000000000..f735cb9eb --- /dev/null +++ b/src/ast/random-ast.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env ts-node +import fc from "fast-check"; +import { randomAstExpression } from "./random.infra"; +import { prettyPrint } from "./ast-printer"; + +const args = process.argv.slice(2); +if (args.length !== 1) { + console.error("Usage: yarn random-ast "); + process.exit(1); +} + +const count = parseInt(args[0] ?? "", 10); +if (isNaN(count) || count <= 0) { + console.error("Error: Count must be a positive integer"); + process.exit(1); +} + +fc.sample(randomAstExpression(4), count).forEach((expression, index) => { + console.log(`Expression ${index + 1}:`); + console.log(prettyPrint(expression)); + console.log("-".repeat(80)); +}); diff --git a/src/ast/random.infra.ts b/src/ast/random.infra.ts new file mode 100644 index 000000000..e79b9a850 --- /dev/null +++ b/src/ast/random.infra.ts @@ -0,0 +1,397 @@ +import fc from "fast-check"; +import * as A from "./ast"; +import { dummySrcInfo } from "../grammar/src-info"; +import { diffJson } from "diff"; + +/** + * An array of reserved words that cannot be used as contract or variable names in tests. + * + * These words are reserved for use in the language and may cause errors + * if attempted to be used as identifiers. + * + * @see src/grammar/next/grammar.gg + */ +const reservedWords = [ + "extend", + "public", + "fun", + "let", + "return", + "receive", + "native", + "primitive", + "null", + "if", + "else", + "while", + "repeat", + "do", + "until", + "try", + "catch", + "foreach", + "as", + "map", + "mutates", + "extends", + "external", + "import", + "with", + "trait", + "initOf", + "override", + "abstract", + "virtual", + "inline", + "const", + "__gen", + "__tact", +]; + +function dummyAstNode( + generator: fc.Arbitrary, +): fc.Arbitrary { + return generator.map((i) => ({ + ...i, + id: 0, + loc: dummySrcInfo, + })); +} + +function randomAstBoolean(): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("boolean"), + value: fc.boolean(), + }), + ); +} + +function randomAstString(): fc.Arbitrary { + const escapeString = (s: string): string => + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return dummyAstNode( + fc.record({ + kind: fc.constant("string"), + value: fc.string().map((s) => escapeString(s)), + }), + ); +} + +function randomAstNumber(): fc.Arbitrary { + const values = [ + ...Array.from({ length: 10 }, (_, i) => [0n, BigInt(i)]).flat(), + ...Array.from({ length: 256 }, (_, i) => 1n ** BigInt(i)), + ]; + + return dummyAstNode( + fc.record({ + kind: fc.constant("number"), + base: fc.constantFrom(2, 8, 10, 16), + value: fc.oneof(...values.map((value) => fc.constant(value))), + }), + ); +} + +function randomAstOpUnary( + operand: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("op_unary"), + op: fc.constantFrom("+", "-", "!", "!!", "~"), + operand: operand, + }), + ); +} +function randomAstOpBinary( + leftExpression: fc.Arbitrary, + rightExpression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("op_binary"), + op: fc.constantFrom( + "+", + "-", + "*", + "/", + "!=", + ">", + "<", + ">=", + "<=", + "==", + "&&", + "||", + "%", + "<<", + ">>", + "&", + "|", + "^", + ), + left: leftExpression, + right: rightExpression, + }), + ); +} + +function randomAstConditional( + conditionExpression: fc.Arbitrary, + thenBranchExpression: fc.Arbitrary, + elseBranchExpression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("conditional"), + condition: conditionExpression, + thenBranch: thenBranchExpression, + elseBranch: elseBranchExpression, + }), + ); +} + +function randomAstId(): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("id"), + text: fc + .stringMatching(/^[A-Za-z_][A-Za-z0-9_]*$/) + .filter( + (i) => + !reservedWords.includes(i) && + !i.startsWith("__gen") && + !i.startsWith("__tact"), + ), + }), + ); +} + +function randomAstCapitalizedId(): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("id"), + text: fc.stringMatching(/^[A-Z][A-Za-z0-9_]*$/), + }), + ); +} + +function randomAstNull(): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("null"), + }), + ); +} + +function randomAstInitOf( + expression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("init_of"), + contract: randomAstId(), + args: fc.array(expression), + }), + ); +} + +function randomAstStaticCall( + expression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("static_call"), + function: randomAstId(), + args: fc.array(expression), + }), + ); +} + +function randomAstStructFieldInitializer( + expression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("struct_field_initializer"), + field: randomAstId(), + initializer: expression, + }), + ); +} + +function randomAstStructInstance( + structFieldInitializer: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("struct_instance"), + type: randomAstCapitalizedId(), + args: fc.array(structFieldInitializer), + }), + ); +} + +function randomAstFieldAccess( + expression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("field_access"), + aggregate: expression, + field: randomAstId(), + }), + ); +} + +function randomAstMethodCall( + selfExpression: fc.Arbitrary, + argsExpression: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + self: selfExpression, + kind: fc.constant("method_call"), + method: randomAstId(), + args: fc.array(argsExpression), + }), + ); +} + +function randomAstStructFieldValue( + subLiteral: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("struct_field_value"), + field: randomAstId(), + initializer: subLiteral, + }), + ); +} + +function randomAstStructValue( + subLiteral: fc.Arbitrary, +): fc.Arbitrary { + return dummyAstNode( + fc.record({ + kind: fc.constant("struct_value"), + type: randomAstCapitalizedId(), + args: fc.array(randomAstStructFieldValue(subLiteral)), + }), + ); +} + +function randomAstLiteral(maxDepth: number): fc.Arbitrary { + return fc.memo((depth: number): fc.Arbitrary => { + if (depth <= 1) { + return fc.oneof( + randomAstNumber(), + randomAstBoolean(), + randomAstNull(), + // Add Address, Cell, Slice + // randomAstCommentValue(), + // randomAstSimplifiedString(), + ); + } + + const subLiteral = () => randomAstLiteral(depth - 1); + + return fc.oneof( + randomAstNumber(), + randomAstBoolean(), + randomAstNull(), + // Add Address, Cell, Slice + // randomAstSimplifiedString(), + // randomAstCommentValue(), + randomAstStructValue(subLiteral()), + ); + })(maxDepth); +} + +export function randomAstExpression( + maxDepth: number, +): fc.Arbitrary { + return fc.memo((depth: number): fc.Arbitrary => { + if (depth <= 1) { + return fc.oneof(randomAstLiteral(depth - 1)); + } + + const subExpr = () => randomAstExpression(depth - 1); + + return fc + .oneof( + randomAstLiteral(maxDepth), + randomAstMethodCall(subExpr(), subExpr()), + randomAstFieldAccess(subExpr()), + randomAstStaticCall(subExpr()), + randomAstStructInstance( + randomAstStructFieldInitializer(subExpr()), + ), + randomAstInitOf(subExpr()), + randomAstString(), + randomAstOpUnary(subExpr()), + randomAstOpBinary(subExpr(), subExpr()), + randomAstConditional(subExpr(), subExpr(), subExpr()), + ) + .filter((i) => i.kind !== "struct_value"); + })(maxDepth); +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).every((key) => typeof key === "string") + ); +} + +function sortObjectKeys>(obj: T): T { + const sortedEntries = Object.entries(obj) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([key, value]) => ({ + [key]: isRecord(value) ? sortObjectKeys(value) : value, + })); + + return Object.assign({}, ...sortedEntries); +} + +export function diffAstObjects( + left: A.AstExpression, + right: A.AstExpression, + prettyBefore: string, + prettyAfter: string, +): void { + const ConsoleColors = { + added: "\x1b[32m", + removed: "\x1b[31m", + reset: "\x1b[0m", + }; + + const replacer = (key: string, value: unknown): unknown => { + if (key === "id") return undefined; + if (typeof value === "bigint") return value.toString(); + return value; + }; + + const leftStr = JSON.stringify(sortObjectKeys(left), replacer, 4); + const rightStr = JSON.stringify(sortObjectKeys(right), replacer, 4); + + const differences = diffJson(leftStr, rightStr); + + differences.forEach((part) => { + const color = part.added + ? ConsoleColors.added + : part.removed + ? ConsoleColors.removed + : ConsoleColors.reset; + + process.stdout.write(color + part.value + ConsoleColors.reset); + }); + + process.stdout.write(`\n\nGenerated to\n\n${prettyBefore}`); + process.stdout.write(`\n\nParsed to\n\n${prettyAfter}\n\n`); +} diff --git a/yarn.lock b/yarn.lock index 9d3e301ae..9364b671b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1406,6 +1406,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/diff@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.0.tgz#e9f6a33a72164b3a80b983114a1cef9a6f0c2165" + integrity sha512-sVpkpbnTJL9CYoDf4U+tHaQLe5HiTaHWY7m9FuYA7oMCHwC9ie0Vh9eIGapyzYrU3+pILlSY2fAc4elfw5m4dg== + "@types/glob@^8.1.0": version "8.1.0" resolved "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz" @@ -2238,6 +2243,11 @@ diff@^4.0.1: resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" @@ -2455,6 +2465,13 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +fast-check@^3.23.2: + version "3.23.2" + resolved "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz#0129f1eb7e4f500f58e8290edc83c670e4a574a2" + integrity sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A== + dependencies: + pure-rand "^6.1.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -3953,7 +3970,7 @@ punycode@^2.1.0: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pure-rand@^6.0.0: +pure-rand@^6.0.0, pure-rand@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==