diff --git a/package.json b/package.json index ca4713b965f..2ebe31ab17d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lerna-update-wizard": "^0.16.0", "lint-staged": "^10.4.2", "nyc": "^13.0.1", - "prettier": "^2.0.5", + "prettier": "^2.2.1", "prs-merged-since": "^1.1.0" }, "workspaces": { diff --git a/packages/parse-mapping-lookup/.eslintrc.json b/packages/parse-mapping-lookup/.eslintrc.json new file mode 100644 index 00000000000..3e111aad02c --- /dev/null +++ b/packages/parse-mapping-lookup/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "overrides": [ + { + "files": ["**/*.spec.ts"], + "env": { + "jest": true + } + } + ] +} diff --git a/packages/parse-mapping-lookup/.gitignore b/packages/parse-mapping-lookup/.gitignore new file mode 100644 index 00000000000..1521c8b7652 --- /dev/null +++ b/packages/parse-mapping-lookup/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/parse-mapping-lookup/.madgerc b/packages/parse-mapping-lookup/.madgerc new file mode 100644 index 00000000000..cd2b9575208 --- /dev/null +++ b/packages/parse-mapping-lookup/.madgerc @@ -0,0 +1,13 @@ +{ + "excludeRegExp": [ + "\\.\\.", + "test" + ], + "fileExtensions": ["ts"], + "detectiveOptions": { + "ts": { + "skipTypeImports": true + } + }, + "tsConfig": "./tsconfig.json" +} diff --git a/packages/parse-mapping-lookup/README.md b/packages/parse-mapping-lookup/README.md new file mode 100644 index 00000000000..317a276e9ac --- /dev/null +++ b/packages/parse-mapping-lookup/README.md @@ -0,0 +1,9 @@ +# `@truffle/parse-mapping-lookup` + +## Usage + +```typescript +import { parse } from "@truffle/parse-mapping-lookup"; + +parse("ledger.balances[0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e]"); +``` diff --git a/packages/parse-mapping-lookup/package.json b/packages/parse-mapping-lookup/package.json new file mode 100644 index 00000000000..4fe836aa1ee --- /dev/null +++ b/packages/parse-mapping-lookup/package.json @@ -0,0 +1,84 @@ +{ + "name": "@truffle/parse-mapping-lookup", + "version": "0.1.0", + "description": "Parse pointers to mapping/struct innards (e.g. m[0])", + "keywords": [ + "smart-contract", + "truffle" + ], + "author": "g. nicholas d'andrea ", + "homepage": "https://github.com/trufflesuite/truffle#readme", + "license": "MIT", + "main": "dist/src/index.js", + "directories": { + "dist": "dist" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/trufflesuite/truffle.git", + "directory": "packages/parse-mapping-lookup" + }, + "scripts": { + "build": "yarn build:dist && yarn build:docs", + "clean": "rm -rf ./dist", + "prepare": "yarn build", + "build:dist": "ttsc", + "build:docs": "typedoc", + "madge": "madge ./src/index.ts", + "test": "jest --verbose --detectOpenHandles --forceExit --passWithNoTests" + }, + "devDependencies": { + "@types/jest": "^26.0.20", + "@types/node": "12.12.21", + "jest": "^26.5.2", + "madge": "^3.11.0", + "ts-jest": "^26.4.1", + "ts-node": "^9.1.1", + "tsconfig-paths": "^3.9.0", + "ttypescript": "^1.5.7", + "typedoc": "^0.20.19", + "typescript": "^4.1.4", + "typescript-transform-paths": "^2.1.0" + }, + "bugs": { + "url": "https://github.com/trufflesuite/truffle/issues" + }, + "dependencies": { + "parjs": "^0.12.7" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js", + "json", + "node" + ], + "testEnvironment": "node", + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "globals": { + "ts-jest": { + "tsConfig": "/tsconfig.json", + "diagnostics": true + } + }, + "moduleNameMapper": { + "^@truffle/parse-mapping-lookup/(.*)": "/src/$1", + "^@truffle/parse-mapping-lookup$": "/src", + "^test/(.*)": "/test/$1" + }, + "testMatch": [ + "/src/**/test/*\\.(ts|js)", + "/test/**/test/*\\.(ts|js)", + "/src/**/*\\.(spec|test)\\.(ts|js)", + "/test/**/*\\.(spec|test)\\.(ts|js)" + ] + } +} diff --git a/packages/parse-mapping-lookup/src/ast.ts b/packages/parse-mapping-lookup/src/ast.ts new file mode 100644 index 00000000000..af450017f3c --- /dev/null +++ b/packages/parse-mapping-lookup/src/ast.ts @@ -0,0 +1,25 @@ +import debugModule from "debug"; +const debug = debugModule("parse-mapping-lookup:ast"); + +import { Forms, definitions } from "./grammar"; +import { Node, makeConstructors } from "./meta"; + +export type Identifier = Node; +export type String = Node; +export type Value = Node; +export type IndexAccess = Node; +export type MemberLookup = Node; +export type Pointer = Node; +export type Expression = Node; + +const constructors = makeConstructors({ definitions }); + +export const { + identifier, + string, + value, + indexAccess, + memberLookup, + pointer, + expression +} = constructors; diff --git a/packages/parse-mapping-lookup/src/grammar.ts b/packages/parse-mapping-lookup/src/grammar.ts new file mode 100644 index 00000000000..2f1720819b7 --- /dev/null +++ b/packages/parse-mapping-lookup/src/grammar.ts @@ -0,0 +1,75 @@ +import { string, regexp, noCharOf } from "parjs"; +import { between, map, then, qthen, many, or } from "parjs/combinators"; + +import { Definitions } from "./meta"; + +import { solidityString } from "./string"; + +export type Forms = { + identifier: { + name: { type: string }; + }; + string: { + contents: { type: string }; + }; + value: { + contents: { type: string }; + }; + indexAccess: { + index: { kind: "string" | "value" }; + }; + memberLookup: { + property: { kind: "identifier" }; + }; + pointer: { + path: Array<{ kind: "memberLookup" | "indexAccess" }>; + }; + expression: { + root: { kind: "identifier" }; + pointer: { kind: "pointer" }; + }; +}; + +export const definitions: Definitions = { + identifier: ({ construct }) => + regexp(/[a-zA-Z_$][a-zA-Z0-9_$]*/).pipe( + map(([name]) => construct({ name })) + ), + + string: ({ construct }) => + solidityString.pipe(map(contents => construct({ contents }))), + + value: ({ construct }) => + noCharOf("]").pipe( + then(noCharOf("]").pipe(many())), + map(([first, rest]) => construct({ contents: [first, ...rest].join("") })) + ), + + indexAccess: ({ construct, tie }) => + tie("string").pipe( + or(tie("value")), + between(string("["), string("]")), + map(index => construct({ index })) + ), + + memberLookup: ({ construct, tie }) => + string(".").pipe( + qthen(tie("identifier")), + map(property => construct({ property })) + ), + + pointer: ({ construct, tie }) => { + const stepP = tie("memberLookup").pipe(or(tie("indexAccess"))); + + return stepP.pipe( + then(stepP.pipe(many())), + map(([first, rest]) => construct({ path: [first, ...rest] })) + ); + }, + + expression: ({ construct, tie }) => + tie("identifier").pipe( + then(tie("pointer")), + map(([root, pointer]) => construct({ root, pointer })) + ) +}; diff --git a/packages/parse-mapping-lookup/src/index.ts b/packages/parse-mapping-lookup/src/index.ts new file mode 100644 index 00000000000..e2e1e490f28 --- /dev/null +++ b/packages/parse-mapping-lookup/src/index.ts @@ -0,0 +1,7 @@ +import debugModule from "debug"; +const debug = debugModule("parse-mapping-lookup"); + +export { parseExpression } from "./parser"; + +import * as Ast from "./ast"; +export { Ast }; diff --git a/packages/parse-mapping-lookup/src/meta/constructors.ts b/packages/parse-mapping-lookup/src/meta/constructors.ts new file mode 100644 index 00000000000..c3b42407d75 --- /dev/null +++ b/packages/parse-mapping-lookup/src/meta/constructors.ts @@ -0,0 +1,36 @@ +import type { Forms, FormKind, Node } from "./forms"; + +export type Constructors = { + [K in FormKind]: Constructor; +}; + +export type Constructor> = ( + fields: Omit, "kind"> +) => Node; + +export type MakeConstructorOptions> = { + kind: K; +}; + +const makeConstructor = >( + options: MakeConstructorOptions +): Constructor => { + const { kind } = options; + return fields => ({ kind, ...fields } as Node); +}; + +export type Definition> = any; +export type MakeConstructorsOptions = { + definitions: { + [K in FormKind]: Definition; + }; +}; + +export const makeConstructors = ( + options: MakeConstructorsOptions +): Constructors => { + const { definitions } = options; + return Object.keys(definitions) + .map(kind => ({ [kind]: makeConstructor({ kind }) })) + .reduce((a, b) => ({ ...a, ...b }), {}) as Constructors; +}; diff --git a/packages/parse-mapping-lookup/src/meta/forms.ts b/packages/parse-mapping-lookup/src/meta/forms.ts new file mode 100644 index 00000000000..ea11416c6f6 --- /dev/null +++ b/packages/parse-mapping-lookup/src/meta/forms.ts @@ -0,0 +1,47 @@ +export type Forms = { + [kind: string]: { + [name: string]: { kind: string } | { kind: string }[] | { type: any }; + }; +}; + +export type FormKind = string & keyof F; +export type Form> = F[K]; + +export type FormFields> = Form; + +export type FormFieldName> = string & + keyof FormFields; + +export type FormField< + F extends Forms, + K extends FormKind, + N extends FormFieldName +> = FormFields[N]; + +export type FormFieldKind< + F extends Forms, + K extends FormKind, + _N extends FormFieldName, + T extends any +> = "kind" extends keyof T ? Node> : never; + +type _FormFieldNode< + F extends Forms, + K extends FormKind, + N extends FormFieldName, + T extends any +> = T extends (infer I)[] + ? _FormFieldNode[] + : "type" extends keyof T + ? T["type"] + : FormFieldKind; + +export type FormFieldNode< + F extends Forms, + K extends FormKind, + N extends FormFieldName +> = _FormFieldNode>; + +export type Node> = { kind: K } & { + [N in FormFieldName]: FormFieldNode; +}; diff --git a/packages/parse-mapping-lookup/src/meta/index.ts b/packages/parse-mapping-lookup/src/meta/index.ts new file mode 100644 index 00000000000..7c77506719b --- /dev/null +++ b/packages/parse-mapping-lookup/src/meta/index.ts @@ -0,0 +1,19 @@ +import type { Node, Forms, FormKind } from "./forms"; +export { Node }; + +import type * as Constructors from "./constructors"; +import { makeConstructors } from "./constructors"; +export { makeConstructors }; + +import type * as Parsers from "./parsers"; +import { makeParsers } from "./parsers"; +export { makeParsers }; + +export type Definition< + F extends Forms, + K extends FormKind +> = Constructors.Definition & Parsers.Definition; + +export type Definitions = { + [K in FormKind]: Definition; +}; diff --git a/packages/parse-mapping-lookup/src/meta/parsers.ts b/packages/parse-mapping-lookup/src/meta/parsers.ts new file mode 100644 index 00000000000..9de60f0f2b6 --- /dev/null +++ b/packages/parse-mapping-lookup/src/meta/parsers.ts @@ -0,0 +1,100 @@ +import type { Parjser } from "parjs"; + +import type { Forms, FormKind, Node } from "./forms"; + +import type { Constructors, Constructor } from "./constructors"; + +export type ParserCombinator> = Parjser< + Node +>; + +export type Tie = >( + kind: K +) => ParserCombinator; + +export type Definition> = (options: { + construct: Constructor; + tie: Tie; +}) => ParserCombinator; + +export type MakeParserCombinatorOptions< + F extends Forms, + K extends FormKind +> = { + kind: K; + definition: Definition; + construct: Constructor; + tie: Tie; +}; + +const makeParserCombinator = >( + options: MakeParserCombinatorOptions +): ParserCombinator => { + const { kind, definition: parser, tie, construct } = options; + + const combinator = parser({ construct, tie }); + return Object.assign(combinator, { + type: `ast:${kind}` + }); +}; + +// prettier-ignore +type ParserName> = /* eslint-disable no-undef */ `parse${Capitalize}`; + +export type Parsers = UnionToIntersection< + { + [K in FormKind]: { + [N in ParserName]: ParserCombinator["parse"]; + }; + }[FormKind] +>; + +export type MakeParsersOptions = { + definitions: { + [K in FormKind]: Definition; + }; + constructors: Constructors; +}; + +export const makeParsers = ( + options: MakeParsersOptions +): Parsers => { + const { definitions, constructors } = options; + const combinators: Partial> = {}; + + function tie>(kind: K) { + if (kind in combinators) { + // @ts-ignore + return combinators[kind]; + } + + const definition = definitions[kind]; + // @ts-ignore + const construct = constructors[kind]; + + // @ts-ignore + combinators[kind] = makeParserCombinator({ + kind, + definition, + tie, + construct + }); + // @ts-ignore + return combinators[kind]; + } + + return Object.keys(definitions) + .map(kind => [kind, `parse${kind.charAt(0).toUpperCase() + kind.slice(1)}`]) + .map(([kind, parserName]) => { + const combinator = tie(kind); + const parser = combinator.parse.bind(combinator); + return { [parserName]: parser }; + }) + .reduce((a, b) => ({ ...a, ...b }), {}) as Parsers; +}; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never; diff --git a/packages/parse-mapping-lookup/src/parser.spec.ts b/packages/parse-mapping-lookup/src/parser.spec.ts new file mode 100644 index 00000000000..580805469f1 --- /dev/null +++ b/packages/parse-mapping-lookup/src/parser.spec.ts @@ -0,0 +1,142 @@ +import { parseExpression } from "@truffle/parse-mapping-lookup/parser"; +import { + expression, + indexAccess, + memberLookup, + identifier, + pointer, + string, + value +} from "@truffle/parse-mapping-lookup/ast"; + +const testCases = [ + { + expression: `m[0]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: value({ contents: "0" }) })] + }) + }) + }, + { + expression: `m[0x0]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: value({ contents: "0x0" }) })] + }) + }) + }, + { + expression: `m["hello"]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: string({ contents: "hello" }) })] + }) + }) + }, + { + expression: `m["\\""]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: string({ contents: '"' }) })] + }) + }) + }, + { + expression: `s.m[0]`, + value: expression({ + root: identifier({ name: "s" }), + pointer: pointer({ + path: [ + memberLookup({ property: identifier({ name: "m" }) }), + indexAccess({ index: value({ contents: "0" }) }) + ] + }) + }) + }, + { + expression: `m$[false]._k[true]`, + value: expression({ + root: identifier({ name: "m$" }), + pointer: pointer({ + path: [ + indexAccess({ index: value({ contents: "false" }) }), + memberLookup({ property: identifier({ name: "_k" }) }), + indexAccess({ index: value({ contents: "true" }) }) + ] + }) + }) + }, + { + expression: `m["\\x41"]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: string({ contents: "A" }) })] + }) + }) + }, + { + expression: `m[`, + trace: { + position: 2 + } + }, + { + expression: `m[1].s[]`, + trace: { + position: 7 + } + }, + { + expression: `m[hex"deadbeef"]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [indexAccess({ index: value({ contents: `hex"deadbeef"` }) })] + }) + }) + }, + { + expression: `m[Direction.North]`, + value: expression({ + root: identifier({ name: "m" }), + pointer: pointer({ + path: [ + indexAccess({ + index: value({ contents: "Direction.North" }) + }) + ] + }) + }) + } +]; + +describe("@truffle/parse-mapping-lookup", () => { + for (const testCase of testCases) { + const { expression } = testCase; + if (testCase.trace) { + const { trace: expected } = testCase; + + it(`fails to parse: ${expression}`, () => { + const result = parseExpression(expression); + expect(result.isOk).toBe(false); + expect( + // @ts-ignore + result.trace + ).toMatchObject(expected); + }); + } else { + it(`parses: ${expression}`, () => { + const { value: expected } = testCase; + const result = parseExpression(expression); + expect(result.isOk).toBeTruthy(); + expect(result.value).toEqual(expected); + }); + } + } +}); diff --git a/packages/parse-mapping-lookup/src/parser.ts b/packages/parse-mapping-lookup/src/parser.ts new file mode 100644 index 00000000000..3f24e49d2ab --- /dev/null +++ b/packages/parse-mapping-lookup/src/parser.ts @@ -0,0 +1,15 @@ +import debugModule from "debug"; +const debug = debugModule("parse-mapping-lookup:parser"); + +import type { ParjsResult } from "parjs"; + +import { Forms, definitions } from "./grammar"; +import * as constructors from "./ast"; +import type { Expression } from "./ast"; +import { makeParsers } from "./meta"; + +export const { + parseExpression +}: { + parseExpression(input: string): ParjsResult; +} = makeParsers({ definitions, constructors }); diff --git a/packages/parse-mapping-lookup/src/string.ts b/packages/parse-mapping-lookup/src/string.ts new file mode 100644 index 00000000000..d13fa96ad54 --- /dev/null +++ b/packages/parse-mapping-lookup/src/string.ts @@ -0,0 +1,63 @@ +/** + * Logic for parsing Solidity strings + * + * This borrows from and repurposes the + * [parjs JSON example](https://github.com/GregRos/parjs/blob/master/src/examples/json.ts). + * + * @packageDocumentation + */ + +import { string, anyCharOf, noCharOf, stringLen } from "parjs"; +import { between, map, qthen, many, or, stringify } from "parjs/combinators"; + +const escapeChars = { + "\n": "\n", + "\\": "\\", + "'": "'", + '"': '"', + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "/": "/" +}; + +const escapeCharP = anyCharOf(Object.keys(escapeChars).join()).pipe( + map(char => escapeChars[char] as string) +); + +const hexEscapeP = string("x").pipe( + qthen( + stringLen(2).pipe( + map(str => parseInt(str, 16)), + map(x => String.fromCharCode(x)) + ) + ) +); + +// A unicode escape sequence is "u" followed by exactly 4 hex digits +const unicodeEscapeP = string("u").pipe( + qthen( + stringLen(4).pipe( + map(str => parseInt(str, 16)), + map(x => String.fromCharCode(x)) + ) + ) +); + +// Any escape sequence begins with a \ +const escapeP = string("\\").pipe( + qthen(escapeCharP.pipe(or(unicodeEscapeP, hexEscapeP))) +); + +// Here we process regular characters vs escape sequences +const stringEntriesP = escapeP.pipe(or(noCharOf('"'))); + +// Repeat the char/escape to get a sequence, and then put between quotes to get a string +export const solidityString = stringEntriesP.pipe( + many(), + stringify(), + between('"') +); diff --git a/packages/parse-mapping-lookup/tsconfig.json b/packages/parse-mapping-lookup/tsconfig.json new file mode 100644 index 00000000000..a41063460f1 --- /dev/null +++ b/packages/parse-mapping-lookup/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "sourceMap": true, + "declaration": true, + "esModuleInterop": true, + "lib": ["esnext"], + "skipLibCheck": true, + "target": "es6", + "moduleResolution": "node", + "downlevelIteration": true, + "allowSyntheticDefaultImports": true, + "module": "commonjs", + "outDir": "./dist", + "strictBindCallApply": true, + "strictNullChecks": true, + "paths": { + "@truffle/parse-mapping-lookup": ["./src"], + "@truffle/parse-mapping-lookup/*": ["./src/*"], + "test/*": ["./test/*"] + }, + "rootDir": ".", + "baseUrl": ".", + "types": [ + "jest", + "node" + ], + "plugins": [ + { "transform": "typescript-transform-paths" }, + { "transform": "typescript-transform-paths", "afterDeclarations": true } + ] + }, + "include": [ + "src/**/*", + "test/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/parse-mapping-lookup/typedoc.js b/packages/parse-mapping-lookup/typedoc.js new file mode 100644 index 00000000000..1a72dc813d6 --- /dev/null +++ b/packages/parse-mapping-lookup/typedoc.js @@ -0,0 +1,7 @@ +module.exports = { + entryPoints: ["src/index.ts"], + categorizeByGroup: false, + readme: "none", + plugin: ["none"], + out: "dist/docs" +}; diff --git a/yarn.lock b/yarn.lock index 90b491b8292..99507ad6aac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8585,6 +8585,13 @@ change-case@^4.1.1: snake-case "^3.0.4" tslib "^2.0.3" +char-info@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/char-info/-/char-info-0.3.2.tgz#d4c4d034245c864d1ab17152cd31746b3bd4f0d0" + integrity sha512-6P6KW8crZx+N857wZalB4FreR+PhheSLmAk22c8zbQsFhsDxZP1aTDfmOjrzddgp1IBOl53b0Z8kDQrwh4B//A== + dependencies: + node-interval-tree "^1.3.3" + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -22666,6 +22673,14 @@ parents@^1.0.0, parents@^1.0.1: dependencies: path-platform "~0.11.15" +parjs@^0.12.7: + version "0.12.7" + resolved "https://registry.yarnpkg.com/parjs/-/parjs-0.12.7.tgz#9dde4975dbbb2f48d4b484524cb359d02395fb72" + integrity sha512-aKw+vsSMBdZNdxcetIwi5mnmFckuwxTz7dKu07wEzrNjKYtvhRlrlla3b4GAEmGMyAWhrocLrUcbSGvmMZMU3Q== + dependencies: + char-info "^0.3.1" + lodash "^4.17.13" + parse-asn1@^5.0.0: version "5.1.5" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.5.tgz#003271343da58dc94cace494faef3d2147ecea0e" @@ -23580,6 +23595,11 @@ prettier@^2.1.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5" integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg== +prettier@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" + integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== + pretty-bytes@^5.4.1: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"