From 5281e44beb89901a406731f6029921c4a00ad4ed Mon Sep 17 00:00:00 2001 From: Georgiy Komarov Date: Sat, 17 Aug 2024 08:45:52 -0400 Subject: [PATCH] feat: AST comparison API (#689) --- .gitignore | 2 +- CHANGELOG.md | 2 + cspell.json | 3 + src/grammar/compare.ts | 806 ++++++++++++++++++ src/grammar/hash.ts | 361 ++++++++ src/grammar/rename.ts | 466 ++++++++++ src/grammar/sort.ts | 55 ++ src/index.ts | 4 + src/test/compare.spec.ts | 27 + src/test/contracts/attributes.tact | 15 + .../proper => contracts}/case-1.tact | 2 +- .../proper => contracts}/case-2.tact | 0 .../proper => contracts}/case-3.tact | 0 .../proper => contracts}/case-4.tact | 2 +- .../case-augmented-assign.tact | 0 .../proper => contracts}/case-bin-ops.tact | 0 .../proper => contracts}/case-if.tact | 0 .../proper => contracts}/case-initOf.tact | 0 .../proper => contracts}/case-loops.tact | 0 .../proper => contracts}/case-receive.tact | 0 .../proper => contracts}/case-traits.tact | 0 .../proper => contracts}/case-trycatch.tact | 0 src/test/contracts/native-functions.tact | 5 + .../renamer-expected/attributes.tact | 16 + .../contracts/renamer-expected/case-1.tact | 22 + .../contracts/renamer-expected/case-2.tact | 13 + .../contracts/renamer-expected/case-3.tact | 31 + .../contracts/renamer-expected/case-4.tact | 55 ++ .../case-augmented-assign.tact | 12 + .../renamer-expected/case-bin-ops.tact | 10 + .../contracts/renamer-expected/case-if.tact | 23 + .../renamer-expected/case-initOf.tact | 15 + .../renamer-expected/case-loops.tact | 29 + .../renamer-expected/case-receive.tact | 42 + .../renamer-expected/case-traits.tact | 38 + .../renamer-expected/case-trycatch.tact | 14 + .../renamer-expected/native-functions.tact | 5 + src/test/prettyPrinter.spec.ts | 35 +- src/test/rename.spec.ts | 33 + src/test/util.ts | 7 + 40 files changed, 2136 insertions(+), 14 deletions(-) create mode 100644 src/grammar/compare.ts create mode 100644 src/grammar/hash.ts create mode 100644 src/grammar/rename.ts create mode 100644 src/grammar/sort.ts create mode 100644 src/test/compare.spec.ts create mode 100644 src/test/contracts/attributes.tact rename src/test/{formatting/proper => contracts}/case-1.tact (99%) rename src/test/{formatting/proper => contracts}/case-2.tact (100%) rename src/test/{formatting/proper => contracts}/case-3.tact (100%) rename src/test/{formatting/proper => contracts}/case-4.tact (99%) rename src/test/{formatting/proper => contracts}/case-augmented-assign.tact (100%) rename src/test/{formatting/proper => contracts}/case-bin-ops.tact (100%) rename src/test/{formatting/proper => contracts}/case-if.tact (100%) rename src/test/{formatting/proper => contracts}/case-initOf.tact (100%) rename src/test/{formatting/proper => contracts}/case-loops.tact (100%) rename src/test/{formatting/proper => contracts}/case-receive.tact (100%) rename src/test/{formatting/proper => contracts}/case-traits.tact (100%) rename src/test/{formatting/proper => contracts}/case-trycatch.tact (100%) create mode 100644 src/test/contracts/native-functions.tact create mode 100644 src/test/contracts/renamer-expected/attributes.tact create mode 100644 src/test/contracts/renamer-expected/case-1.tact create mode 100644 src/test/contracts/renamer-expected/case-2.tact create mode 100644 src/test/contracts/renamer-expected/case-3.tact create mode 100644 src/test/contracts/renamer-expected/case-4.tact create mode 100644 src/test/contracts/renamer-expected/case-augmented-assign.tact create mode 100644 src/test/contracts/renamer-expected/case-bin-ops.tact create mode 100644 src/test/contracts/renamer-expected/case-if.tact create mode 100644 src/test/contracts/renamer-expected/case-initOf.tact create mode 100644 src/test/contracts/renamer-expected/case-loops.tact create mode 100644 src/test/contracts/renamer-expected/case-receive.tact create mode 100644 src/test/contracts/renamer-expected/case-traits.tact create mode 100644 src/test/contracts/renamer-expected/case-trycatch.tact create mode 100644 src/test/contracts/renamer-expected/native-functions.tact create mode 100644 src/test/rename.spec.ts create mode 100644 src/test/util.ts diff --git a/.gitignore b/.gitignore index 7ffdeb777..52dfbea8b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ output/ src/grammar/grammar.ohm-bundle.js src/grammar/grammar.ohm-bundle.d.ts src/func/funcfiftlib.wasm.js - +src/test/contracts/pretty-printer-output diff --git a/CHANGELOG.md b/CHANGELOG.md index f4073ddb2..fc6951b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Initial version of the API providing AST equivalence check: PR [#689](https://github.com/tact-lang/tact/pull/689) + ### Changed ### Fixed diff --git a/cspell.json b/cspell.json index be79de2bb..d3c6396d2 100644 --- a/cspell.json +++ b/cspell.json @@ -81,6 +81,9 @@ "привет", "PUSHREF", "PUSHSLICE", + "renamer", + "Brujin", + "dentry", "SETINDEXVARQ", "gettest" ], diff --git a/src/grammar/compare.ts b/src/grammar/compare.ts new file mode 100644 index 000000000..3a1ea12cc --- /dev/null +++ b/src/grammar/compare.ts @@ -0,0 +1,806 @@ +import { + AstConstantDef, + AstReceiverKind, + AstStructFieldInitializer, + AstFunctionAttribute, + AstOpBinary, + AstOpUnary, + AstFieldAccess, + AstConditional, + AstMethodCall, + AstStaticCall, + AstNumber, + AstBoolean, + AstString, + AstStructInstance, + AstInitOf, + AstConstantAttribute, + AstContractAttribute, + AstTypedParameter, + AstImport, + AstNativeFunctionDecl, + AstReceiver, + AstStatementRepeat, + AstStatementUntil, + AstStatementWhile, + AstStatementForEach, + AstStatementTry, + AstStatementTryCatch, + AstCondition, + AstStatementAugmentedAssign, + AstStatementAssign, + AstStatementExpression, + AstStatementReturn, + AstStatementLet, + AstFunctionDef, + AstContract, + AstTrait, + AstId, + AstModule, + AstStructDecl, + AstMessageDecl, + AstFunctionDecl, + AstConstantDecl, + AstContractInit, + AstPrimitiveTypeDecl, + AstTypeId, + AstMapType, + AstBouncedMessageType, + AstFieldDecl, + AstOptionalType, + AstNode, + AstFuncId, +} from "./ast"; +import { AstRenamer } from "./rename"; +import { throwInternalCompilerError } from "../errors"; +import JSONbig from "json-bigint"; + +/** + * Provides an API to compare two AST nodes with extra options. + */ +export class AstComparator { + /** + * @param sort Topologically sort AST entries before comparing. Should be enabled + * in order to handle duplicate entries shuffled in the source code. + * @param canonicalize Introduce de Brujin indices for local bindings to handle + * duplicate code with different names. Should be enabled in order to + * treat duplicate entries with different names as the same elements. + */ + private constructor( + private readonly sort: boolean, + private readonly canonicalize: boolean, + ) {} + + public static make( + options: Partial<{ sort: boolean; canonicalize: boolean }> = {}, + ): AstComparator { + const { sort = true, canonicalize = true } = options; + return new AstComparator(sort, canonicalize); + } + + public compare(node1: AstNode, node2: AstNode): boolean { + if (node1.kind !== node2.kind) { + return false; + } + + switch (node1.kind) { + case "module": { + if (this.canonicalize) { + const renamer = AstRenamer.make({ sort: this.sort }); + node1 = renamer.renameModule(node1 as AstModule); + node2 = renamer.renameModule(node2 as AstModule); + } + const { imports: imports1, items: items1 } = node1 as AstModule; + const { imports: imports2, items: items2 } = node2 as AstModule; + return ( + this.compareArray(imports1, imports2) && + this.compareArray(items1, items2) + ); + } + + case "import": { + const { path: path1 } = node1 as AstImport; + const { path: path2 } = node2 as AstImport; + return this.compare(path1, path2); + } + + case "primitive_type_decl": { + const { name: name1 } = node1 as AstPrimitiveTypeDecl; + const { name: name2 } = node2 as AstPrimitiveTypeDecl; + return this.compare(name1, name2); + } + + case "function_def": { + const { + attributes: attributes1, + name: funcName1, + return: returnType1, + params: params1, + statements: statements1, + } = node1 as AstFunctionDef; + const { + attributes: attributes2, + name: funcName2, + return: returnType2, + params: params2, + statements: statements2, + } = node2 as AstFunctionDef; + return ( + this.compareAttributes(attributes1, attributes2) && + this.compare(funcName1, funcName2) && + this.compareNullableNodes(returnType1, returnType2) && + this.compareArray(params1, params2) && + this.compareArray(statements1, statements2) + ); + } + + case "function_decl": { + const { + attributes: declAttributes1, + name: declName1, + return: declReturnType1, + params: declParams1, + } = node1 as AstFunctionDecl; + const { + attributes: declAttributes2, + name: declName2, + return: declReturnType2, + params: declParams2, + } = node2 as AstFunctionDecl; + return ( + this.compareAttributes(declAttributes1, declAttributes2) && + this.compare(declName1, declName2) && + this.compareNullableNodes( + declReturnType1, + declReturnType2, + ) && + this.compareArray(declParams1, declParams2) + ); + } + + case "native_function_decl": { + const { + attributes: nativeAttributes1, + name: nativeName1, + nativeName: nativeFuncName1, + params: nativeParams1, + return: returnTy1, + } = node1 as AstNativeFunctionDecl; + const { + attributes: nativeAttributes2, + name: nativeName2, + nativeName: nativeFuncName2, + params: nativeParams2, + return: returnTy2, + } = node2 as AstNativeFunctionDecl; + return ( + this.compareAttributes( + nativeAttributes1, + nativeAttributes2, + ) && + this.compare(nativeName1, nativeName2) && + this.compare(nativeFuncName1, nativeFuncName2) && + this.compareNullableNodes(returnTy1, returnTy2) && + this.compareArray(nativeParams1, nativeParams2) + ); + } + + case "constant_def": { + const { + attributes: constAttributes1, + name: constName1, + type: constType1, + initializer: constInitializer1, + } = node1 as AstConstantDef; + const { + attributes: constAttributes2, + name: constName2, + type: constType2, + initializer: constInitializer2, + } = node2 as AstConstantDef; + return ( + this.compareAttributes( + constAttributes1, + constAttributes2, + ) && + this.compare(constName1, constName2) && + this.compare(constType1, constType2) && + this.compare(constInitializer1, constInitializer2) + ); + } + + case "constant_decl": { + const { + attributes: constDeclAttributes1, + name: constDeclName1, + type: constDeclType1, + } = node1 as AstConstantDecl; + const { + attributes: constDeclAttributes2, + name: constDeclName2, + type: constDeclType2, + } = node2 as AstConstantDecl; + return ( + this.compareAttributes( + constDeclAttributes1, + constDeclAttributes2, + ) && + this.compare(constDeclName1, constDeclName2) && + this.compare(constDeclType1, constDeclType2) + ); + } + + case "struct_decl": { + const { name: structName1, fields: structFields1 } = + node1 as AstStructDecl; + const { name: structName2, fields: structFields2 } = + node2 as AstStructDecl; + return ( + this.compare(structName1, structName2) && + this.compareArray(structFields1, structFields2) + ); + } + + case "message_decl": { + const { name: msgName1, fields: msgFields1 } = + node1 as AstMessageDecl; + const { name: msgName2, fields: msgFields2 } = + node2 as AstMessageDecl; + return ( + this.compare(msgName1, msgName2) && + this.compareArray(msgFields1, msgFields2) + ); + } + + case "contract": { + const { + name: contractName1, + traits: contractTraits1, + attributes: contractAttributes1, + declarations: contractDeclarations1, + } = node1 as AstContract; + const { + name: contractName2, + traits: contractTraits2, + attributes: contractAttributes2, + declarations: contractDeclarations2, + } = node2 as AstContract; + return ( + this.compare(contractName1, contractName2) && + this.compareArray(contractTraits1, contractTraits2) && + this.compareAttributes( + contractAttributes1, + contractAttributes2, + ) && + this.compareArray( + contractDeclarations1, + contractDeclarations2, + ) + ); + } + + case "trait": { + const { + name: traitName1, + traits: traits1, + attributes: attributes1, + declarations: declarations1, + } = node1 as AstTrait; + const { + name: traitName2, + traits: traits2, + attributes: attributes2, + declarations: declarations2, + } = node2 as AstTrait; + return ( + this.compare(traitName1, traitName2) && + this.compareArray(traits1, traits2) && + this.compareAttributes(attributes1, attributes2) && + this.compareArray(declarations1, declarations2) + ); + } + + case "field_decl": { + const { + name: fieldName1, + type: fieldType1, + initializer: fieldInitializer1, + as: as1, + } = node1 as AstFieldDecl; + const { + name: fieldName2, + type: fieldType2, + initializer: fieldInitializer2, + as: as2, + } = node2 as AstFieldDecl; + return ( + this.compare(fieldName1, fieldName2) && + this.compare(fieldType1, fieldType2) && + this.compareNullableNodes( + fieldInitializer1, + fieldInitializer2, + ) && + this.compareNullableNodes(as1, as2) + ); + } + + case "receiver": { + const { + selector: receiverSelector1, + statements: receiverStatements1, + } = node1 as AstReceiver; + const { + selector: receiverSelector2, + statements: receiverStatements2, + } = node2 as AstReceiver; + return ( + this.compareReceiverKinds( + receiverSelector1, + receiverSelector2, + ) && + this.compareArray(receiverStatements1, receiverStatements2) + ); + } + + case "contract_init": { + const { params: initParams1, statements: initStatements1 } = + node1 as AstContractInit; + const { params: initParams2, statements: initStatements2 } = + node2 as AstContractInit; + return ( + this.compareArray(initParams1, initParams2) && + this.compareArray(initStatements1, initStatements2) + ); + } + + case "statement_let": { + const { + name: name1, + type: ty1, + expression: expr1, + } = node1 as AstStatementLet; + const { + name: name2, + type: ty2, + expression: expr2, + } = node2 as AstStatementLet; + return ( + this.compare(name1, name2) && + this.compareNullableNodes(ty1, ty2) && + this.compare(expr1, expr2) + ); + } + + case "statement_return": { + const { expression: expr1 } = node1 as AstStatementReturn; + const { expression: expr2 } = node2 as AstStatementReturn; + return this.compareNullableNodes(expr1, expr2); + } + + case "statement_expression": { + const { expression: expr1 } = node1 as AstStatementExpression; + const { expression: expr2 } = node2 as AstStatementExpression; + return this.compareNullableNodes(expr1, expr2); + } + + case "statement_assign": { + const { path: assignPath1, expression: assignExpression1 } = + node1 as AstStatementAssign; + const { path: assignPath2, expression: assignExpression2 } = + node2 as AstStatementAssign; + return ( + this.compare(assignPath1, assignPath2) && + this.compare(assignExpression1, assignExpression2) + ); + } + + case "statement_augmentedassign": { + const { + op: augOp1, + path: augPath1, + expression: augExpression1, + } = node1 as AstStatementAugmentedAssign; + const { + op: augOp2, + path: augPath2, + expression: augExpression2, + } = node2 as AstStatementAugmentedAssign; + return ( + augOp1 === augOp2 && + this.compare(augPath1, augPath2) && + this.compare(augExpression1, augExpression2) + ); + } + + case "statement_condition": { + const { + condition: cond1, + trueStatements: true1, + falseStatements: false1, + elseif: condElseIf1, + } = node1 as AstCondition; + const { + condition: cond2, + trueStatements: true2, + falseStatements: false2, + elseif: condElseIf2, + } = node2 as AstCondition; + return ( + this.compare(cond1, cond2) && + this.compareArray(true1, true2) && + this.compareNullableArray(false1, false2) && + this.compareNullableNodes(condElseIf1, condElseIf2) + ); + } + + case "statement_while": { + const { + condition: loopCondition1, + statements: loopStatements1, + } = node1 as AstStatementWhile; + const { + condition: loopCondition2, + statements: loopStatements2, + } = node2 as AstStatementWhile; + return ( + this.compare(loopCondition1, loopCondition2) && + this.compareArray(loopStatements1, loopStatements2) + ); + } + + case "statement_until": { + const { + condition: loopCondition1, + statements: loopStatements1, + } = node1 as AstStatementUntil; + const { + condition: loopCondition2, + statements: loopStatements2, + } = node2 as AstStatementUntil; + return ( + this.compare(loopCondition1, loopCondition2) && + this.compareArray(loopStatements1, loopStatements2) + ); + } + + case "statement_repeat": { + const { iterations: iter1, statements: stmts1 } = + node1 as AstStatementRepeat; + const { iterations: iter2, statements: stmts2 } = + node2 as AstStatementRepeat; + return ( + this.compare(iter1, iter2) && + this.compareArray(stmts1, stmts2) + ); + } + + case "statement_try": { + const { statements: tryStatements1 } = node1 as AstStatementTry; + const { statements: tryStatements2 } = node2 as AstStatementTry; + return this.compareArray(tryStatements1, tryStatements2); + } + + case "statement_try_catch": { + const { + statements: tryCatchStatements1, + catchName: catchName1, + catchStatements: catchStatements1, + } = node1 as AstStatementTryCatch; + const { + statements: tryCatchStatements2, + catchName: catchName2, + catchStatements: catchStatements2, + } = node2 as AstStatementTryCatch; + return ( + this.compareArray( + tryCatchStatements1, + tryCatchStatements2, + ) && + this.compare(catchName1, catchName2) && + this.compareArray(catchStatements1, catchStatements2) + ); + } + + case "statement_foreach": { + const { + keyName: forEachKeyName1, + valueName: forEachValueName1, + map: forEachMap1, + statements: forEachStatements1, + } = node1 as AstStatementForEach; + const { + keyName: forEachKeyName2, + valueName: forEachValueName2, + map: forEachMap2, + statements: forEachStatements2, + } = node2 as AstStatementForEach; + return ( + this.compare(forEachKeyName1, forEachKeyName2) && + this.compare(forEachValueName1, forEachValueName2) && + this.compare(forEachMap1, forEachMap2) && + this.compareArray(forEachStatements1, forEachStatements2) + ); + } + + case "type_id": { + const { text: typeIdText1 } = node1 as AstTypeId; + const { text: typeIdText2 } = node2 as AstTypeId; + return typeIdText1 === typeIdText2; + } + + case "optional_type": { + const { typeArg: optionalTypeArg1 } = node1 as AstOptionalType; + const { typeArg: optionalTypeArg2 } = node2 as AstOptionalType; + return this.compare(optionalTypeArg1, optionalTypeArg2); + } + + case "map_type": { + const { + keyType: mapKeyType1, + keyStorageType: mapKeyStorageType1, + valueType: mapValueType1, + valueStorageType: mapValueStorageType1, + } = node1 as AstMapType; + const { + keyType: mapKeyType2, + keyStorageType: mapKeyStorageType2, + valueType: mapValueType2, + valueStorageType: mapValueStorageType2, + } = node2 as AstMapType; + return ( + this.compare(mapKeyType1, mapKeyType2) && + this.compareNullableNodes( + mapKeyStorageType1, + mapKeyStorageType2, + ) && + this.compare(mapValueType1, mapValueType2) && + this.compareNullableNodes( + mapValueStorageType1, + mapValueStorageType2, + ) + ); + } + + case "bounced_message_type": { + const { messageType: messageTy1 } = + node1 as AstBouncedMessageType; + const { messageType: messageTy2 } = + node2 as AstBouncedMessageType; + return this.compare(messageTy1, messageTy2); + } + + case "op_binary": { + const { + op: binaryOp1, + left: lhs1, + right: rhs1, + } = node1 as AstOpBinary; + const { + op: binaryOp2, + left: lhs2, + right: rhs2, + } = node2 as AstOpBinary; + return ( + binaryOp1 === binaryOp2 && + this.compare(lhs1, lhs2) && + this.compare(rhs1, rhs2) + ); + } + + case "op_unary": { + const { op: op1, operand: operand1 } = node1 as AstOpUnary; + const { op: op2, operand: operand2 } = node2 as AstOpUnary; + return op1 === op2 && this.compare(operand1, operand2); + } + + case "field_access": { + const { aggregate: aggregate1, field: field1 } = + node1 as AstFieldAccess; + const { aggregate: aggregate2, field: field2 } = + node2 as AstFieldAccess; + return ( + this.compare(aggregate1, aggregate2) && + this.compare(field1, field2) + ); + } + + case "method_call": { + const { + self: self1, + method: method1, + args: args1, + } = node1 as AstMethodCall; + const { + self: self2, + method: method2, + args: args2, + } = node2 as AstMethodCall; + return ( + this.compare(self1, self2) && + this.compare(method1, method2) && + this.compareArray(args1, args2) + ); + } + + case "static_call": { + const { function: staticFunction1, args: staticArgs1 } = + node1 as AstStaticCall; + const { function: staticFunction2, args: staticArgs2 } = + node2 as AstStaticCall; + return ( + this.compare(staticFunction1, staticFunction2) && + this.compareArray(staticArgs1, staticArgs2) + ); + } + + case "struct_instance": { + const { type: ty1, args: args1 } = node1 as AstStructInstance; + const { type: ty2, args: args2 } = node2 as AstStructInstance; + return ( + this.compare(ty1, ty2) && this.compareArray(args1, args2) + ); + } + + case "init_of": { + const { contract: initOfContract1, args: initOfArgs1 } = + node1 as AstInitOf; + const { contract: initOfContract2, args: initOfArgs2 } = + node2 as AstInitOf; + return ( + this.compare(initOfContract1, initOfContract2) && + this.compareArray(initOfArgs1, initOfArgs2) + ); + } + + case "conditional": { + const { + condition: cond1, + thenBranch: then1, + elseBranch: else1, + } = node1 as AstConditional; + const { + condition: cond2, + thenBranch: then2, + elseBranch: else2, + } = node2 as AstConditional; + return ( + this.compare(cond1, cond2) && + this.compare(then1, then2) && + this.compare(else1, else2) + ); + } + + case "id": { + const { text: text1 } = node1 as AstId; + const { text: text2 } = node2 as AstId; + return text1 === text2; + } + + case "func_id": { + const { text: text1 } = node1 as AstFuncId; + const { text: text2 } = node2 as AstFuncId; + return text1 === text2; + } + + case "number": { + const { value: val1 } = node1 as AstNumber; + const { value: val2 } = node2 as AstNumber; + return val1 === val2; + } + + case "boolean": { + const { value: val1 } = node1 as AstBoolean; + const { value: val2 } = node2 as AstBoolean; + return val1 === val2; + } + + case "string": { + const { value: val1 } = node1 as AstString; + const { value: val2 } = node2 as AstString; + return val1 === val2; + } + + case "null": { + return true; + } + + case "typed_parameter": { + const { name: name1, type: ty1 } = node1 as AstTypedParameter; + const { name: name2, type: ty2 } = node1 as AstTypedParameter; + return this.compare(name1, name2) && this.compare(ty1, ty2); + } + + case "struct_field_initializer": { + const { field: field1, initializer: initializer1 } = + node1 as AstStructFieldInitializer; + const { field: field2, initializer: initializer2 } = + node2 as AstStructFieldInitializer; + return ( + this.compare(field1, field2) && + this.compare(initializer1, initializer2) + ); + } + + default: + throwInternalCompilerError( + `Unsupported node: ${JSONbig.stringify(node1)}`, + ); + } + } + + private compareNullableNodes( + node1: AstNode | null, + node2: AstNode | null, + ): boolean { + if (node1 === null || node2 === null) { + return node1 === node2; + } + return this.compare(node1, node2); + } + + private compareArray(nodes1: AstNode[], nodes2: AstNode[]): boolean { + if (nodes1.length !== nodes2.length) { + return false; + } + for (let i = 0; i < nodes1.length; i++) { + if (!this.compare(nodes1[i]!, nodes2[i]!)) { + return false; + } + } + return true; + } + + private compareNullableArray( + nodes1: AstNode[] | null, + nodes2: AstNode[] | null, + ): boolean { + if (nodes1 === null || nodes2 === null) { + return nodes1 === nodes2; + } + return this.compareArray(nodes1, nodes2); + } + + private compareAttributes< + T extends + | AstFunctionAttribute + | AstConstantAttribute + | AstContractAttribute, + >(attrs1: T[], attrs2: T[]): boolean { + if (attrs1.length !== attrs2.length) { + return false; + } + for (let i = 0; i < attrs1.length; i++) { + if (attrs1[i]!.type !== attrs2[i]!.type) { + return false; + } + } + return true; + } + + private compareReceiverKinds( + kind1: AstReceiverKind, + kind2: AstReceiverKind, + ): boolean { + if (kind1.kind !== kind2.kind) { + return false; + } + if ( + (kind1.kind === "internal-simple" && + kind2.kind === "internal-simple") || + (kind1.kind === "bounce" && kind2.kind === "bounce") || + (kind1.kind === "external-simple" && + kind2.kind === "external-simple") + ) { + return this.compare(kind1.param, kind2.param); + } + if ( + (kind1.kind === "internal-comment" && + kind2.kind === "internal-comment") || + (kind1.kind === "external-comment" && + kind2.kind === "external-comment") + ) { + return this.compare(kind1.comment, kind2.comment); + } + return true; + } +} diff --git a/src/grammar/hash.ts b/src/grammar/hash.ts new file mode 100644 index 000000000..73bd4f075 --- /dev/null +++ b/src/grammar/hash.ts @@ -0,0 +1,361 @@ +import { + AstConstantDef, + AstModuleItem, + AstStatement, + AstStructFieldInitializer, + AstFunctionAttribute, + AstConstantAttribute, + AstContractAttribute, + AstTypedParameter, + AstImport, + AstNativeFunctionDecl, + AstReceiver, + AstFunctionDef, + AstContract, + AstTrait, + AstId, + AstModule, + AstStructDecl, + AstMessageDecl, + AstFunctionDecl, + AstConstantDecl, + AstContractInit, + AstFieldDecl, + AstNode, +} from "./ast"; +import { createHash } from "crypto"; +import { throwInternalCompilerError } from "../errors"; +import JSONbig from "json-bigint"; + +export type AstHash = string; + +/** + * Provides functionality to hash AST nodes regardless of identifiers. + */ +export class AstHasher { + private constructor(private readonly sort: boolean) {} + public static make(params: Partial<{ sort: boolean }> = {}): AstHasher { + const { sort = true } = params; + return new AstHasher(sort); + } + + public hash(node: AstNode): AstHash { + const data = + node.kind === "id" || node.kind === "func_id" + ? `${node.kind}_${node.text}` + : this.getHashData(node); + return createHash("sha256").update(data).digest("hex"); + } + + /** + * Generates a string that is used to create a hash. + */ + private getHashData(node: AstNode): string { + switch (node.kind) { + case "module": + return this.hashModule(node); + case "struct_decl": + return this.hashStructDecl(node); + case "message_decl": + return this.hashMessageDecl(node); + case "function_def": + return this.hashFunctionDef(node); + case "constant_def": + return this.hashConstantDef(node); + case "trait": + return this.hashTrait(node); + case "contract": + return this.hashContract(node); + case "field_decl": + return this.hashFieldDecl(node); + case "primitive_type_decl": + return `${node.kind}|${node.name.kind}`; + case "contract_init": + return this.hashContractInit(node); + case "native_function_decl": + return this.hashNativeFunctionDecl(node); + case "receiver": + return this.hashReceiver(node); + case "id": + return "id"; + case "func_id": + return "func_id"; + case "typed_parameter": + return this.hashTypedParameter(node); + case "function_decl": + return this.hashFunctionDecl(node); + case "struct_field_initializer": + return this.hashStructFieldInitializer(node); + case "import": + return this.hashImport(node); + case "constant_decl": + return this.hashConstantDecl(node); + // Statements + case "statement_let": + return `${node.kind}|${node.type ? this.hash(node.type) : "null"}|${this.hash(node.expression)}`; + case "statement_return": + return `${node.kind}|${node.expression ? this.hash(node.expression) : "null"}`; + case "statement_expression": + return `${node.kind}|${this.hash(node.expression)}`; + case "statement_assign": + return `${node.kind}|${this.hash(node.path)}|${this.hash(node.expression)}`; + case "statement_augmentedassign": + return `${node.kind}|${node.op}|${this.hash(node.path)}|${this.hash(node.expression)}`; + case "statement_condition": { + const trueStatementsHash = this.hashStatements( + node.trueStatements, + ); + const falseStatementsHash = node.falseStatements + ? this.hashStatements(node.falseStatements) + : "null"; + const elseifHash = node.elseif + ? this.hash(node.elseif) + : "null"; + return `${node.kind}|${this.hash(node.condition)}|${trueStatementsHash}|${falseStatementsHash}|${elseifHash}`; + } + case "statement_while": + return `${node.kind}|${this.hash(node.condition)}|${this.hashStatements(node.statements)}`; + case "statement_until": + return `${node.kind}|${this.hash(node.condition)}|${this.hashStatements(node.statements)}`; + case "statement_repeat": + return `${node.kind}|${this.hash(node.iterations)}|${this.hashStatements(node.statements)}`; + case "statement_try": + return `${node.kind}|${this.hashStatements(node.statements)}`; + case "statement_try_catch": + return `${node.kind}|${this.hashStatements(node.statements)}|${this.hash(node.catchName)}|${this.hashStatements(node.catchStatements)}`; + case "statement_foreach": + return `${node.kind}|${this.hash(node.map)}|${this.hashStatements(node.statements)}`; + // Expressions + case "op_binary": + return `${node.kind}|${node.op}|${this.hash(node.left)}|${this.hash(node.right)}`; + case "op_unary": + return `${node.kind}|${node.op}|${this.hash(node.operand)}`; + case "field_access": + return `${node.kind}|${this.hash(node.aggregate)}|${node.field.kind}`; + case "method_call": { + const argsHash = node.args + .map((arg) => this.hash(arg)) + .join("|"); + return `${node.kind}|${argsHash}`; + } + case "static_call": { + const staticArgsHash = node.args + .map((arg) => this.hash(arg)) + .join("|"); + return `${node.kind}|${staticArgsHash}`; + } + case "struct_instance": { + const structArgsHash = node.args + .map((arg) => this.hashStructFieldInitializer(arg)) + .join("|"); + return `${node.kind}|${structArgsHash}`; + } + case "init_of": { + const initArgsHash = node.args + .map((arg) => this.hash(arg)) + .join("|"); + return `${node.kind}|${initArgsHash}`; + } + case "conditional": + return `${node.kind}|${this.hash(node.condition)}|${this.hash(node.thenBranch)}|${this.hash(node.elseBranch)}`; + case "number": + return `${node.kind}|${node.value}`; + case "boolean": + return `${node.kind}|${node.value}`; + case "string": + return `${node.kind}|${node.value}`; + case "null": + return node.kind; + // Types + case "type_id": + return `${node.kind}|${node.text}`; + case "optional_type": + return `${node.kind}|${this.hash(node.typeArg)}`; + case "map_type": { + const keyStorageHash = node.keyStorageType + ? this.hash(node.keyStorageType) + : "null"; + const valueStorageHash = node.valueStorageType + ? this.hash(node.valueStorageType) + : "null"; + return `${node.kind}|${this.hash(node.keyType)}|${keyStorageHash}|${this.hash(node.valueType)}|${valueStorageHash}`; + } + case "bounced_message_type": + return `${node.kind}|${this.hash(node.messageType)}`; + default: + throwInternalCompilerError( + `Unsupported node: ${JSONbig.stringify(node)}`, + ); + } + } + + private hashStructDecl(node: AstStructDecl): string { + const fieldsHash = this.hashFields(node.fields); + return `struct|${fieldsHash}`; + } + + private hashMessageDecl(node: AstMessageDecl): string { + const fieldsHash = this.hashFields(node.fields); + return `message|${fieldsHash}|${node.opcode}`; + } + + private hashFunctionDef(node: AstFunctionDef): string { + const attributesHash = this.hashAttributes(node.attributes); + const returnHash = node.return ? this.hash(node.return) : "void"; + const paramsHash = this.hashParams(node.params); + const statementsHash = this.hashStatements(node.statements); + return `function|${attributesHash}|${returnHash}|${paramsHash}|${statementsHash}`; + } + + private hashConstantDef(node: AstConstantDef): string { + const attributesHash = this.hashAttributes(node.attributes); + const typeHash = this.hash(node.type); + const initializerHash = this.hash(node.initializer); + return `constant|${attributesHash}|${typeHash}|${initializerHash}`; + } + + private hashTrait(node: AstTrait): string { + const traitsHash = this.hashIds(node.traits); + const attributesHash = this.hashContractAttributes(node.attributes); + const declarationsHash = this.hashDeclarations(node.declarations); + return `trait|${traitsHash}|${attributesHash}|${declarationsHash}`; + } + + private hashContract(node: AstContract): string { + const traitsHash = this.hashIds(node.traits); + const attributesHash = this.hashContractAttributes(node.attributes); + const declarationsHash = this.hashDeclarations(node.declarations); + return `contract|${traitsHash}|${attributesHash}|${declarationsHash}`; + } + + private hashFields(fields: AstFieldDecl[]): string { + let hashedFields = fields.map((field) => this.hashFieldDecl(field)); + if (this.sort) { + hashedFields = hashedFields.sort(); + } + return hashedFields.join("|"); + } + + private hashParams(params: AstTypedParameter[]): string { + let hashedParams = params.map((param) => + this.hashTypedParameter(param), + ); + if (this.sort) { + hashedParams = hashedParams.sort(); + } + return hashedParams.join("|"); + } + + private hashTypedParameter(param: AstTypedParameter): string { + const typeHash = this.hash(param.type); + return `param|${typeHash}`; + } + + private hashAttributes( + attributes: (AstFunctionAttribute | AstConstantAttribute)[], + ): string { + return attributes + .map((attr) => attr.type) + .sort() + .join("|"); + } + + private hashContractAttributes(attributes: AstContractAttribute[]): string { + return attributes + .map((attr) => `${attr.type}|${attr.name.value}`) + .sort() + .join("|"); + } + + private hashIds(ids: AstId[]): string { + return ids + .map((id) => id.kind) + .sort() + .join("|"); // Ignore actual id.text, just hash based on kind + } + + private hashDeclarations(declarations: AstNode[]): string { + let hashedDeclarations = declarations.map((decl) => this.hash(decl)); + if (this.sort) { + hashedDeclarations = hashedDeclarations.sort(); + } + return hashedDeclarations.join("|"); + } + + private hashStatements(statements: AstStatement[]): string { + let hashedStatements = statements.map((stmt) => this.hash(stmt)); + if (this.sort) { + hashedStatements = hashedStatements.sort(); + } + return hashedStatements.join("|"); + } + + private hashStructFieldInitializer( + initializer: AstStructFieldInitializer, + ): string { + return `field_initializer|${this.hash(initializer.initializer)}`; + } + + private hashFieldDecl(field: AstFieldDecl): string { + const typeHash = this.hash(field.type); + return `field|${typeHash}`; + } + + private hashContractInit(node: AstContractInit): string { + const paramsHash = this.hashParams(node.params); + const statementsHash = this.hashStatements(node.statements); + return `${node.kind}|${paramsHash}|${statementsHash}`; + } + + private hashNativeFunctionDecl(node: AstNativeFunctionDecl): string { + const attributesHash = this.hashAttributes(node.attributes); + const paramsHash = this.hashParams(node.params); + const returnHash = node.return ? this.hash(node.return) : "void"; + return `${node.kind}|${attributesHash}|${paramsHash}|${returnHash}`; + } + + private hashReceiver(node: AstReceiver): string { + const selectorHash = node.selector.kind; + const statementsHash = this.hashStatements(node.statements); + return `${node.kind}|${selectorHash}|${statementsHash}`; + } + + private hashFunctionDecl(node: AstFunctionDecl): string { + const attributesHash = this.hashAttributes(node.attributes); + const returnHash = node.return ? this.hash(node.return) : "void"; + const paramsHash = this.hashParams(node.params); + return `${node.kind}|${attributesHash}|${returnHash}|${paramsHash}`; + } + + private hashImport(node: AstImport): string { + return `${node.kind}|${this.hash(node.path)}`; + } + + private hashConstantDecl(node: AstConstantDecl): string { + const attributesHash = this.hashAttributes(node.attributes); + const typeHash = this.hash(node.type); + return `${node.kind}|${attributesHash}|${typeHash}`; + } + + private hashModule(node: AstModule): string { + const importsHash = this.hashImports(node.imports); + const itemsHash = this.hashModuleItems(node.items); + return `${node.kind}|${importsHash}|${itemsHash}`; + } + + private hashImports(imports: AstImport[]): string { + let hashedImports = imports.map((imp) => this.hash(imp)); + if (this.sort) { + hashedImports = hashedImports.sort(); + } + return hashedImports.join("|"); + } + + private hashModuleItems(items: AstModuleItem[]): string { + let hashedItems = items.map((item) => this.hash(item)); + if (this.sort) { + hashedItems = hashedItems.sort(); + } + return hashedItems.join("|"); + } +} diff --git a/src/grammar/rename.ts b/src/grammar/rename.ts new file mode 100644 index 000000000..baeaa68b9 --- /dev/null +++ b/src/grammar/rename.ts @@ -0,0 +1,466 @@ +import { + AstConstantDef, + AstModuleItem, + AstStatement, + AstModule, + AstTraitDeclaration, + AstContractDeclaration, + AstExpression, + AstStructFieldInitializer, + AstCondition, + AstFunctionDef, + AstContract, + AstTrait, + AstId, + AstFunctionDecl, + AstConstantDecl, + AstNode, +} from "./ast"; +import { dummySrcInfo } from "./grammar"; +import { AstSorter } from "./sort"; +import { AstHasher, AstHash } from "./hash"; + +type GivenName = string; + +function id(text: string): AstId { + return { kind: "id", text, id: 0, loc: dummySrcInfo }; +} + +/** + * An utility class that provides alpha-renaming and topological sort functionality + * for the AST comparison. + */ +export class AstRenamer { + private constructor( + private sort: boolean, + private currentIdx: number = 0, + private renamed: Map = new Map(), + private givenNames: Map = new Map(), + ) {} + public static make(params: Partial<{ sort: boolean }> = {}): AstRenamer { + const { sort = true } = params; + return new AstRenamer(sort); + } + + /** + * Renames the given node based on its AST. + */ + public renameModule(module: AstModule): AstNode { + return { + ...module, + items: this.renameModuleItems(module.items), + }; + } + + private nextIdx(): number { + const value = this.currentIdx; + this.currentIdx += 1; + return value; + } + + /** + * Generates a new unique node name. + */ + private generateName(node: AstNode): GivenName { + return `${node.kind}_${this.nextIdx()}`; + } + + /** + * Tries to get an identifier based on the node definition. + */ + private getName(node: AstNode): string | undefined { + switch (node.kind) { + case "id": + case "func_id": + return node.text; + case "primitive_type_decl": + case "native_function_decl": + case "struct_decl": + case "message_decl": + case "constant_def": + case "constant_decl": + case "function_def": + case "function_decl": + case "trait": + case "contract": + return node.name.text; + default: + return undefined; + } + } + + /** + * Sets new or an existent name based on node's hash. + */ + private setName(node: AstNode, forceName?: string): GivenName { + const hash = AstHasher.make({ sort: this.sort }).hash(node); + const giveNewName = (newName: string) => { + const name = this.getName(node); + if (name !== undefined) { + this.givenNames.set(name, newName); + } + }; + const existentName = this.renamed.get(hash); + if (existentName !== undefined) { + giveNewName(existentName); + return existentName; + } + const name = forceName ?? this.generateName(node); + this.renamed.set(hash, name); + giveNewName(name); + return name; + } + + public renameModuleItems(items: AstModuleItem[]): AstModuleItem[] { + // Give new names to module-level elements. + let renamedItems = items.map((item) => this.changeItemName(item)); + + if (this.sort) { + renamedItems.map((item) => this.sortAttributes(item)); + } + + // Apply renaming to the contents of these elements. + renamedItems = renamedItems.map((item) => + this.renameModuleItemContents(item), + ); + + return this.sort ? this.sortModuleItems(renamedItems) : renamedItems; + } + + /** + * Lexicographically sort items based on their kinds and then by their names. + */ + private sortModuleItems(items: AstModuleItem[]): AstModuleItem[] { + const kindOrder = { + primitive_type_decl: 1, + native_function_decl: 2, + struct_decl: 3, + message_decl: 4, + constant_def: 5, + function_def: 6, + trait: 7, + contract: 8, + }; + return items.sort((a, b) => { + const kindComparison = kindOrder[a.kind] - kindOrder[b.kind]; + if (kindComparison !== 0) { + return kindComparison; + } + return a.name.text.localeCompare(b.name.text); + }); + } + + /** + * Changes the name of a top-level/contract/trait element without inspecting its body. + */ + private changeItemName< + T extends AstModuleItem | AstConstantDecl | AstFunctionDecl, + >(item: T): T { + switch (item.kind) { + case "primitive_type_decl": + return item; // Skip renaming + case "native_function_decl": { + const newName = this.setName( + item, + `native_${item.nativeName.text}`, + ); + return { ...item, name: id(newName) }; + } + case "contract": { + const newName = this.setName(item); + const declarations = item.declarations.map((decl) => { + if ( + decl.kind === "function_def" || + decl.kind === "constant_def" + ) { + return this.changeItemName( + decl, + ) as AstContractDeclaration; + } else { + return decl; + } + }); + return { ...item, name: id(newName), declarations }; + } + case "trait": { + const newName = this.setName(item); + const declarations = item.declarations.map((decl) => { + if ( + decl.kind === "function_def" || + decl.kind === "constant_def" || + decl.kind === "function_decl" || + decl.kind === "constant_decl" + ) { + return this.changeItemName(decl) as AstTraitDeclaration; + } else { + return decl; + } + }); + return { ...item, name: id(newName), declarations }; + } + default: { + const newName = this.setName(item); + return { ...item, name: id(newName) }; + } + } + } + + /** + * Renames the contents of an AstModuleItem based on its kind. + */ + private renameModuleItemContents(item: AstModuleItem): AstModuleItem { + switch (item.kind) { + case "struct_decl": + case "message_decl": + return item; + case "function_def": + return this.renameFunctionContents(item as AstFunctionDef); + case "constant_def": + return this.renameConstantContents(item as AstConstantDef); + case "trait": + return this.renameTraitContents(item as AstTrait); + case "contract": + return this.renameContractContents(item as AstContract); + default: + return item; // No further renaming needed for other kinds + } + } + + /** + * Sorts attributes within an item if available. + */ + private sortAttributes< + T extends AstModuleItem | AstContractDeclaration | AstTraitDeclaration, + >(item: T): T { + switch (item.kind) { + case "trait": + case "contract": + return { + ...item, + attributes: AstSorter.sortAttributes(item.attributes), + declarations: item.declarations.map((decl) => + this.sortAttributes(decl), + ), + }; + case "constant_decl": + case "constant_def": + return { + ...item, + attributes: AstSorter.sortAttributes(item.attributes), + }; + case "function_decl": + case "function_def": + return { + ...item, + attributes: AstSorter.sortAttributes(item.attributes), + }; + default: + return item; + } + } + + /** + * Renames the contents of a function. + */ + private renameFunctionContents( + functionDef: AstFunctionDef, + ): AstFunctionDef { + const statements = this.renameStatements(functionDef.statements); + return { ...functionDef, statements }; + } + + /** + * Renames the contents of a constant, focusing on the initializer. + */ + private renameConstantContents( + constantDef: AstConstantDef, + ): AstConstantDef { + const initializer = this.renameExpression(constantDef.initializer); + return { ...constantDef, initializer }; + } + + /** + * Renames the contents of a trait, including its declarations. + */ + private renameTraitContents(trait: AstTrait): AstTrait { + const declarations = trait.declarations.map((decl) => { + if (decl.kind === "function_def") { + return this.renameFunctionContents(decl as AstFunctionDef); + } else if (decl.kind === "constant_def") { + return this.renameConstantContents(decl as AstConstantDef); + } else { + return decl; + } + }); + return { ...trait, declarations }; + } + + /** + * Renames the contents of a contract, including its declarations and parameters. + */ + private renameContractContents(contract: AstContract): AstContract { + const declarations = contract.declarations.map((decl) => { + if (decl.kind === "function_def") { + return this.renameFunctionContents(decl as AstFunctionDef); + } else if (decl.kind === "constant_def") { + return this.renameConstantContents(decl as AstConstantDef); + } else { + return decl; + } + }); + return { ...contract, declarations }; + } + + private renameStatements(statements: AstStatement[]): AstStatement[] { + return statements.map((stmt) => { + return this.renameStatement(stmt); + }); + } + + private renameStatement(stmt: AstStatement): AstStatement { + switch (stmt.kind) { + case "statement_let": + return { + ...stmt, + expression: this.renameExpression(stmt.expression), + }; + case "statement_return": + return { + ...stmt, + expression: stmt.expression + ? this.renameExpression(stmt.expression) + : null, + }; + case "statement_expression": + return { + ...stmt, + expression: this.renameExpression(stmt.expression), + }; + case "statement_assign": + return { + ...stmt, + path: this.renameExpression(stmt.path), + expression: this.renameExpression(stmt.expression), + }; + case "statement_augmentedassign": + return { + ...stmt, + path: this.renameExpression(stmt.path), + expression: this.renameExpression(stmt.expression), + }; + case "statement_condition": + return { + ...stmt, + condition: this.renameExpression(stmt.condition), + trueStatements: this.renameStatements(stmt.trueStatements), + falseStatements: stmt.falseStatements + ? this.renameStatements(stmt.falseStatements) + : null, + elseif: stmt.elseif + ? (this.renameStatement(stmt.elseif) as AstCondition) + : null, + }; + case "statement_while": + case "statement_until": + return { + ...stmt, + condition: this.renameExpression(stmt.condition), + statements: this.renameStatements(stmt.statements), + }; + case "statement_repeat": + return { + ...stmt, + iterations: this.renameExpression(stmt.iterations), + statements: this.renameStatements(stmt.statements), + }; + case "statement_try": + return { + ...stmt, + statements: this.renameStatements(stmt.statements), + }; + case "statement_try_catch": + return { + ...stmt, + statements: this.renameStatements(stmt.statements), + catchStatements: this.renameStatements( + stmt.catchStatements, + ), + }; + case "statement_foreach": + return { + ...stmt, + map: this.renameExpression(stmt.map), + statements: this.renameStatements(stmt.statements), + }; + default: + return stmt; + } + } + + private renameExpression(expr: AstExpression): AstExpression { + switch (expr.kind) { + case "id": + return { + ...expr, + text: this.renamed.get(expr.text) ?? expr.text, + }; + case "op_binary": + return { + ...expr, + left: this.renameExpression(expr.left), + right: this.renameExpression(expr.right), + }; + case "op_unary": + return { + ...expr, + operand: this.renameExpression(expr.operand), + }; + case "field_access": + return { + ...expr, + aggregate: this.renameExpression(expr.aggregate), + }; + case "method_call": + case "static_call": + return { + ...expr, + args: expr.args.map((arg) => this.renameExpression(arg)), + }; + case "struct_instance": + return { + ...expr, + args: expr.args.map((arg) => + this.renameStructFieldInitializer(arg), + ), + }; + case "init_of": + return { + ...expr, + args: expr.args.map((arg) => this.renameExpression(arg)), + }; + case "conditional": + return { + ...expr, + condition: this.renameExpression(expr.condition), + thenBranch: this.renameExpression(expr.thenBranch), + elseBranch: this.renameExpression(expr.elseBranch), + }; + case "number": + case "boolean": + case "string": + case "null": + return expr; + default: + return expr; + } + } + + private renameStructFieldInitializer( + initializer: AstStructFieldInitializer, + ): AstStructFieldInitializer { + return { + ...initializer, + initializer: this.renameExpression(initializer.initializer), + }; + } +} diff --git a/src/grammar/sort.ts b/src/grammar/sort.ts new file mode 100644 index 000000000..41af072eb --- /dev/null +++ b/src/grammar/sort.ts @@ -0,0 +1,55 @@ +import { + AstPrimitiveTypeDecl, + AstFunctionAttribute, + AstConstantAttribute, + AstContractAttribute, + AstNode, +} from "./ast"; +import { throwInternalCompilerError } from "../errors"; + +/** + * Provides utilities to sort lists of AST nodes. + */ +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class AstSorter { + public static sort(items: T[]): T[] { + if (items.length === 0) { + return items; + } + const kind = items[0]!.kind; + switch (kind) { + case "primitive_type_decl": + return this.sortPrimitiveTypeDecls( + items as AstPrimitiveTypeDecl[], + ) as T[]; + default: + throwInternalCompilerError(`Unsupported node kind: ${kind}`); + } + } + + private static sortPrimitiveTypeDecls( + decls: AstPrimitiveTypeDecl[], + ): AstPrimitiveTypeDecl[] { + return decls.sort((a, b) => { + // Case-insensitive sorting + const nameA = a.name.text.toLowerCase(); + const nameB = b.name.text.toLowerCase(); + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + return 0; + }); + } + + public static sortAttributes< + T extends + | AstConstantAttribute + | AstContractAttribute + | AstFunctionAttribute, + >(attributes: T[]): T[] { + return attributes.sort((a, b) => a.type.localeCompare(b.type)); + } +} diff --git a/src/index.ts b/src/index.ts index c9a352643..f4c1222d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,7 @@ export { TactConstEvalError, TactErrorCollection, } from "./errors"; +export { AstSorter } from "./grammar/sort"; +export { AstRenamer } from "./grammar/rename"; +export { AstHasher } from "./grammar/hash"; +export { AstComparator } from "./grammar/compare"; diff --git a/src/test/compare.spec.ts b/src/test/compare.spec.ts new file mode 100644 index 000000000..d8dd63bcb --- /dev/null +++ b/src/test/compare.spec.ts @@ -0,0 +1,27 @@ +import fs from "fs"; +import { __DANGER_resetNodeId } from "../grammar/ast"; +import { parse } from "../grammar/grammar"; +import { join } from "path"; +import { AstComparator } from "../grammar/compare"; +import { CONTRACTS_DIR } from "./util"; +import * as assert from "assert"; + +describe("comparator", () => { + it.each(fs.readdirSync(CONTRACTS_DIR, { withFileTypes: true }))( + "AST modules of the same file must be equal", + (dentry) => { + if (!dentry.isFile()) { + return; + } + const filePath = join(CONTRACTS_DIR, dentry.name); + const src = fs.readFileSync(filePath, "utf-8"); + const ast1 = parse(src, filePath, "user"); + const ast2 = parse(src, filePath, "user"); + assert.strictEqual( + AstComparator.make().compare(ast1, ast2), + true, + `The AST comparison failed for ${dentry.name}`, + ); + }, + ); +}); diff --git a/src/test/contracts/attributes.tact b/src/test/contracts/attributes.tact new file mode 100644 index 000000000..a43adf586 --- /dev/null +++ b/src/test/contracts/attributes.tact @@ -0,0 +1,15 @@ +extends mutates fun fun1(self: Int, c: Int) { + let res: Int = 1; + repeat (c) { + res *= self; + } + self = res; +} + +mutates extends fun fun2(self: Int, c: Int) { + let res: Int = 1; + repeat (c) { + res *= self; + } + self = res; +} diff --git a/src/test/formatting/proper/case-1.tact b/src/test/contracts/case-1.tact similarity index 99% rename from src/test/formatting/proper/case-1.tact rename to src/test/contracts/case-1.tact index d5437932f..aaa2e2635 100644 --- a/src/test/formatting/proper/case-1.tact +++ b/src/test/contracts/case-1.tact @@ -19,4 +19,4 @@ contract Empty { get fun a(x: Int, y: Int, z: Bool, m: Source): Bool { return isZero(x, y, z, m); } -} \ No newline at end of file +} diff --git a/src/test/formatting/proper/case-2.tact b/src/test/contracts/case-2.tact similarity index 100% rename from src/test/formatting/proper/case-2.tact rename to src/test/contracts/case-2.tact diff --git a/src/test/formatting/proper/case-3.tact b/src/test/contracts/case-3.tact similarity index 100% rename from src/test/formatting/proper/case-3.tact rename to src/test/contracts/case-3.tact diff --git a/src/test/formatting/proper/case-4.tact b/src/test/contracts/case-4.tact similarity index 99% rename from src/test/formatting/proper/case-4.tact rename to src/test/contracts/case-4.tact index f0f01d957..42ae6e25d 100644 --- a/src/test/formatting/proper/case-4.tact +++ b/src/test/contracts/case-4.tact @@ -52,4 +52,4 @@ contract SampleContract { receive("increment") { self.a -= 1; } -} \ No newline at end of file +} diff --git a/src/test/formatting/proper/case-augmented-assign.tact b/src/test/contracts/case-augmented-assign.tact similarity index 100% rename from src/test/formatting/proper/case-augmented-assign.tact rename to src/test/contracts/case-augmented-assign.tact diff --git a/src/test/formatting/proper/case-bin-ops.tact b/src/test/contracts/case-bin-ops.tact similarity index 100% rename from src/test/formatting/proper/case-bin-ops.tact rename to src/test/contracts/case-bin-ops.tact diff --git a/src/test/formatting/proper/case-if.tact b/src/test/contracts/case-if.tact similarity index 100% rename from src/test/formatting/proper/case-if.tact rename to src/test/contracts/case-if.tact diff --git a/src/test/formatting/proper/case-initOf.tact b/src/test/contracts/case-initOf.tact similarity index 100% rename from src/test/formatting/proper/case-initOf.tact rename to src/test/contracts/case-initOf.tact diff --git a/src/test/formatting/proper/case-loops.tact b/src/test/contracts/case-loops.tact similarity index 100% rename from src/test/formatting/proper/case-loops.tact rename to src/test/contracts/case-loops.tact diff --git a/src/test/formatting/proper/case-receive.tact b/src/test/contracts/case-receive.tact similarity index 100% rename from src/test/formatting/proper/case-receive.tact rename to src/test/contracts/case-receive.tact diff --git a/src/test/formatting/proper/case-traits.tact b/src/test/contracts/case-traits.tact similarity index 100% rename from src/test/formatting/proper/case-traits.tact rename to src/test/contracts/case-traits.tact diff --git a/src/test/formatting/proper/case-trycatch.tact b/src/test/contracts/case-trycatch.tact similarity index 100% rename from src/test/formatting/proper/case-trycatch.tact rename to src/test/contracts/case-trycatch.tact diff --git a/src/test/contracts/native-functions.tact b/src/test/contracts/native-functions.tact new file mode 100644 index 000000000..6d91dc7c6 --- /dev/null +++ b/src/test/contracts/native-functions.tact @@ -0,0 +1,5 @@ +@name(hello_world) +native helloWorld(): Int; + +@name(__tact_compute_contract_address) +native contractAddressExt(chain: Int, code: Cell, data: Cell): Address; diff --git a/src/test/contracts/renamer-expected/attributes.tact b/src/test/contracts/renamer-expected/attributes.tact new file mode 100644 index 000000000..ed4a73604 --- /dev/null +++ b/src/test/contracts/renamer-expected/attributes.tact @@ -0,0 +1,16 @@ +extends mutates fun function_def_0(self: Int, c: Int) { + let res: Int = 1; + repeat (c) { + res *= self; + } + self = res; +} + +extends mutates fun function_def_0(self: Int, c: Int) { + let res: Int = 1; + repeat (c) { + res *= self; + } + self = res; +} + diff --git a/src/test/contracts/renamer-expected/case-1.tact b/src/test/contracts/renamer-expected/case-1.tact new file mode 100644 index 000000000..8fdf8d841 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-1.tact @@ -0,0 +1,22 @@ +struct struct_decl_0 { + a: Int; + b: Int; + c: Int; + d: Int; +} + +fun function_def_1(x: Int, y: Int, z: Bool, m: Source): Bool { + let b: Int = x + y; + b = b + 1 + m.a + m.b; + let c: Int = y >> 123; + let d: Int = x << 10; + return b > 0 && z && c == 0 && d == 0; +} + +contract contract_2 { + init() {} + + get fun function_def_3(x: Int, y: Int, z: Bool, m: Source): Bool { + return isZero(x, y, z, m); + } +} diff --git a/src/test/contracts/renamer-expected/case-2.tact b/src/test/contracts/renamer-expected/case-2.tact new file mode 100644 index 000000000..25915e9db --- /dev/null +++ b/src/test/contracts/renamer-expected/case-2.tact @@ -0,0 +1,13 @@ +struct struct_decl_0 { + a: Int; + b: Int; +} + +fun function_def_1(x: Int, y: Int, z: Bool, m: Source): Bool { + m.b = 10; + return x + m.b > 0 && z; +} + +contract contract_2 { + init() {} +} diff --git a/src/test/contracts/renamer-expected/case-3.tact b/src/test/contracts/renamer-expected/case-3.tact new file mode 100644 index 000000000..ca0dd84c5 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-3.tact @@ -0,0 +1,31 @@ +struct struct_decl_0 { + a: Int; + b: Int; +} + +fun function_def_1(x: Int, y: Int, z: Bool, m: Source): Bool { + m.b = 10; + return 2 * x + m.b > 0 && z; +} + +contract contract_2 { + a: Int; + b: Int; + c: Source; + + init() { + self.a = 0; + self.b = 0; + self.c = Source{a: 0, b: 0}; + } + + fun function_def_3() { + self.a = 10; + self.b = -20; + self.c = Source{a: 10, b: 20}; + } + + get fun function_def_4(): Int { + return self.a; + } +} diff --git a/src/test/contracts/renamer-expected/case-4.tact b/src/test/contracts/renamer-expected/case-4.tact new file mode 100644 index 000000000..a6f007b01 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-4.tact @@ -0,0 +1,55 @@ +import "@stdlib/deploy"; + +primitive NInt; + +@name(store_uint) +native native_store_uint(s: Builder, value: Int, bits: Int): Builder; + +struct struct_decl_0 { + a: Int; + b: Int; +} + +const constant_def_2: Int = 0; +const constant_def_3: String = "string"; +const constant_def_4: Bool = true; +const constant_def_5: Bool = false; + +fun function_def_1(x: Int, y: Int, z: Bool, m: Source): Bool { + m.b = 10; + return x + m.b > 0 && z; +} + +contract contract_6 { + a: Int; + b: Int; + c: Source; + d: map; + + const constant_def_7: Int = 42; + + init() { + self.a = 0; + self.b = 0; + self.c = Source{a: 0, b: 0}; + } + + fun function_def_8() { + let d: Int? = null; + self.a = 10; + d = a > 0 ? self.a : 0; + let res: Bool = isZero(1, 2, false, self.c); + let e = 42; + self.b = a; + self.c = Source{a: 10, b: 20}; + } + + get fun function_def_9(): Int { + self.addStake(); + return self.a; + } + + receive("increment") { + self.a -= 1; + } +} diff --git a/src/test/contracts/renamer-expected/case-augmented-assign.tact b/src/test/contracts/renamer-expected/case-augmented-assign.tact new file mode 100644 index 000000000..9c3df22bb --- /dev/null +++ b/src/test/contracts/renamer-expected/case-augmented-assign.tact @@ -0,0 +1,12 @@ +contract contract_0 { + a: Int; + + init() { + self.a = 0; + self.a += 2; + self.a -= 1; + self.a *= 2; + self.a /= 2; + self.a %= 5; + } +} diff --git a/src/test/contracts/renamer-expected/case-bin-ops.tact b/src/test/contracts/renamer-expected/case-bin-ops.tact new file mode 100644 index 000000000..b720ef96f --- /dev/null +++ b/src/test/contracts/renamer-expected/case-bin-ops.tact @@ -0,0 +1,10 @@ +contract contract_0 { + a: Int; + + init() { + self.a = (1 + 2 - 3) / 4 % 5 | 255 & 53 ^ 2; + if (1 > 2 || 3 == 0 && (5 - 3) * 10 > 0) { + + } + } +} diff --git a/src/test/contracts/renamer-expected/case-if.tact b/src/test/contracts/renamer-expected/case-if.tact new file mode 100644 index 000000000..a189f4935 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-if.tact @@ -0,0 +1,23 @@ +fun function_def_0(x: Int, y: Int): Int { + if (x < y) { + return x; + } else { + return y; + } +} + +contract contract_1 { + x: Int as int32; + + init() { + if (1 > 0) { + if (2 > 3) { + self.x = 1; + } else { + self.x = 2; + } + } else { + self.x = 0; + } + } +} diff --git a/src/test/contracts/renamer-expected/case-initOf.tact b/src/test/contracts/renamer-expected/case-initOf.tact new file mode 100644 index 000000000..42c942908 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-initOf.tact @@ -0,0 +1,15 @@ +contract contract_0 { + a: Int; + b: Bool; + + init(a: Int, b: Bool) { + self.a = a; + self.b = b; + } +} + +contract contract_1 { + init() { + initOf A(1, false); + } +} diff --git a/src/test/contracts/renamer-expected/case-loops.tact b/src/test/contracts/renamer-expected/case-loops.tact new file mode 100644 index 000000000..3b7845447 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-loops.tact @@ -0,0 +1,29 @@ +contract contract_0 { + x: Int; + y: map; + + init() { + self.x = 5; + self.y = emptyMap(); + self.y.set(1, 42); + elf.y.set(2, 3); + let y: map = emptyMap(); + y.set(1, 42); + y.set(2, 3); + while (self.x > 0) { + self.x = self.x - 1; + } + repeat (self.x) { + self.x += self.x; + } + do { + self.x = self.x + 1; + } until (self.x < 10); + foreach (k, v in y) { + self.x += v; + } + foreach (k, v in self.y) { + self.x += v; + } + } +} diff --git a/src/test/contracts/renamer-expected/case-receive.tact b/src/test/contracts/renamer-expected/case-receive.tact new file mode 100644 index 000000000..d20fd57dd --- /dev/null +++ b/src/test/contracts/renamer-expected/case-receive.tact @@ -0,0 +1,42 @@ +message message_decl_0 { + value: Int; +} + +contract contract_1 { + a: Int; + + init() { + self.a = 0; + } + + receive() { + self.a = 1; + } + + receive("message") { + self.a = 2; + } + + receive(m: MyMessage) { + self.a = m.value; + } + + bounced(m: bounced) { + self.a = 3; + } + + external() { + self.a = 4; + acceptMessage(); + } + + external("message") { + self.a = 5; + acceptMessage(); + } + + external(m: MyMessage) { + self.a = m.value; + acceptMessage(); + } +} diff --git a/src/test/contracts/renamer-expected/case-traits.tact b/src/test/contracts/renamer-expected/case-traits.tact new file mode 100644 index 000000000..967178b84 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-traits.tact @@ -0,0 +1,38 @@ +@interface("") trait trait_0 { +} + +trait trait_1 { + abstract get fun function_decl_2(e: String): String; +} + +trait trait_3 with B { + owner: Address; + value: Int; + + const constant_def_4: Int = 2; + abstract const constant_decl_5: Int; + + receive("message") { + + } + + fun function_def_6() { + nativeThrowUnless(132, context().sender == self.owner); + } + + get fun function_def_7(): Address { + return self.owner; + } +} + +@interface("a") contract contract_8 with Ownable { + owner: Address; + value: Int; + + const constant_def_4: Int = 2; + + init(owner: Address) { + self.owner = owner; + self.value = 1; + } +} diff --git a/src/test/contracts/renamer-expected/case-trycatch.tact b/src/test/contracts/renamer-expected/case-trycatch.tact new file mode 100644 index 000000000..46a450001 --- /dev/null +++ b/src/test/contracts/renamer-expected/case-trycatch.tact @@ -0,0 +1,14 @@ +fun function_def_0() { + try { + nativeThrow(42); + } + dump(42); +} + +fun function_def_1() { + try { + nativeThrow(42); + } catch (err) { + dump(err); + } +} diff --git a/src/test/contracts/renamer-expected/native-functions.tact b/src/test/contracts/renamer-expected/native-functions.tact new file mode 100644 index 000000000..16d106009 --- /dev/null +++ b/src/test/contracts/renamer-expected/native-functions.tact @@ -0,0 +1,5 @@ +@name(__tact_compute_contract_address) +native native___tact_compute_contract_address(chain: Int, code: Cell, data: Cell): Address; + +@name(hello_world) +native native_hello_world(): Int; diff --git a/src/test/prettyPrinter.spec.ts b/src/test/prettyPrinter.spec.ts index df14509d6..79bdb13f6 100644 --- a/src/test/prettyPrinter.spec.ts +++ b/src/test/prettyPrinter.spec.ts @@ -3,32 +3,45 @@ import { __DANGER_resetNodeId } from "../grammar/ast"; import { prettyPrint } from "../prettyPrinter"; import { parse } from "../grammar/grammar"; import { join } from "path"; +import { trimTrailingCR, CONTRACTS_DIR } from "./util"; +import * as assert from "assert"; import JSONBig from "json-bigint"; describe("formatter", () => { - it.each(fs.readdirSync(join(__dirname, "formatting", "proper")))( + it.each(fs.readdirSync(CONTRACTS_DIR, { withFileTypes: true }))( "shouldn't change proper formatting", - (file) => { - const filePath = join(__dirname, "formatting", "proper", file); - const src = fs.readFileSync(filePath, "utf-8"); + (dentry) => { + if (!dentry.isFile()) { + return; + } + const filePath = join(CONTRACTS_DIR, dentry.name); + const src = trimTrailingCR(fs.readFileSync(filePath, "utf-8")); const ast = parse(src, filePath, "user"); - const formatted = prettyPrint(ast); - expect(formatted).toEqual(src); + const formatted = trimTrailingCR(prettyPrint(ast)); + assert.strictEqual( + formatted, + src, + `The formatted AST comparison failed for ${dentry.name}`, + ); }, ); - const outputDir = join(__dirname, "formatting", "output"); + + const outputDir = join(CONTRACTS_DIR, "pretty-printer-output"); fs.mkdirSync(outputDir, { recursive: true }); - it.each(fs.readdirSync(join(__dirname, "formatting", "proper")))( + it.each(fs.readdirSync(CONTRACTS_DIR, { withFileTypes: true }))( "shouldn't change AST", - (file) => { - const filePath = join(__dirname, "formatting", "proper", file); + (dentry) => { + if (!dentry.isFile()) { + return; + } + const filePath = join(CONTRACTS_DIR, dentry.name); const src = fs.readFileSync(filePath, "utf-8"); const ast = parse(src, filePath, "user"); //TODO: change for proper recursive removal const astStr = JSONBig.stringify(ast).replace(/"id":[0-9]+,/g, ""); const formatted = prettyPrint(ast); - const fileName = join(outputDir, file); + const fileName = join(outputDir, dentry.name); fs.openSync(fileName, "w"); fs.writeFileSync(fileName, formatted, { flag: "w" }); const astFormatted = parse(formatted, fileName, "user"); diff --git a/src/test/rename.spec.ts b/src/test/rename.spec.ts new file mode 100644 index 000000000..297549376 --- /dev/null +++ b/src/test/rename.spec.ts @@ -0,0 +1,33 @@ +import fs from "fs"; +import { __DANGER_resetNodeId } from "../grammar/ast"; +import { parse } from "../grammar/grammar"; +import { join } from "path"; +import { AstRenamer } from "../grammar/rename"; +import { prettyPrint } from "../prettyPrinter"; +import { trimTrailingCR, CONTRACTS_DIR } from "./util"; +import * as assert from "assert"; + +const EXPECTED_DIR = join(CONTRACTS_DIR, "renamer-expected"); + +describe("renamer", () => { + it.each(fs.readdirSync(CONTRACTS_DIR, { withFileTypes: true }))( + "should have an expected content after being renamed", + (dentry) => { + if (!dentry.isFile()) { + return; + } + const expectedFilePath = join(EXPECTED_DIR, dentry.name); + const expected = fs.readFileSync(expectedFilePath, "utf-8"); + const filePath = join(CONTRACTS_DIR, dentry.name); + const src = fs.readFileSync(filePath, "utf-8"); + const inAst = parse(src, filePath, "user"); + const outAst = AstRenamer.make().renameModule(inAst); + const got = prettyPrint(outAst); + assert.strictEqual( + trimTrailingCR(got), + trimTrailingCR(expected), + `AST comparison after renamed failed for ${dentry.name}`, + ); + }, + ); +}); diff --git a/src/test/util.ts b/src/test/util.ts new file mode 100644 index 000000000..4377ffb6b --- /dev/null +++ b/src/test/util.ts @@ -0,0 +1,7 @@ +import path from "path"; + +export const CONTRACTS_DIR = path.join(__dirname, "contracts"); + +export function trimTrailingCR(input: string): string { + return input.replace(/\n+$/, ""); +}