diff --git a/packages/transactions/src/cl.ts b/packages/transactions/src/cl.ts index fe54bfa87..04f6cd57c 100644 --- a/packages/transactions/src/cl.ts +++ b/packages/transactions/src/cl.ts @@ -18,7 +18,8 @@ import { uintCV, } from './clarity'; -export { prettyPrint } from './clarity/prettyPrint'; +export { prettyPrint, stringify } from './clarity/prettyPrint'; +export { parse } from './clarity/parser'; // todo: https://github.com/hirosystems/clarinet/issues/786 diff --git a/packages/transactions/src/clarity/parser.ts b/packages/transactions/src/clarity/parser.ts new file mode 100644 index 000000000..6513a1dd1 --- /dev/null +++ b/packages/transactions/src/clarity/parser.ts @@ -0,0 +1,329 @@ +import { Cl, ClarityValue, TupleCV } from '..'; + +// COMBINATOR TYPES +type Combinator = (str: string) => ParseResult; + +type ParseResult = ParseSuccess | ParseFail; + +type Capture = ClarityValue | string; + +interface ParseSuccess { + success: true; + value: string; + rest: string; + capture?: Capture; +} + +interface ParseFail { + success: false; +} + +// GENERAL COMBINATORS +function regex(pattern: RegExp, map?: (value: string) => ClarityValue): Combinator { + return (s: string) => { + const match = s.match(pattern); + if (!match || match.index !== 0) return { success: false }; + return { + success: true, + value: match[0], + rest: s.substring(match[0].length), + capture: map ? map(match[0]) : undefined, + }; + }; +} + +function whitespace(): Combinator { + return regex(/\s+/); +} + +function lazy(c: () => Combinator): Combinator { + return (s: string) => c()(s); +} + +function either(combinators: Combinator[]): Combinator { + return (s: string) => { + for (const c of combinators) { + const result = c(s); + if (result.success) return result; + } + return { success: false }; + }; +} + +function entire(combinator: Combinator): Combinator { + return (s: string) => { + const result = combinator(s); + if (!result.success || result.rest) return { success: false }; + return result; + }; +} + +function optional(c: Combinator): Combinator { + return (s: string) => { + const result = c(s); + if (result.success) return result; + return { + success: true, + value: '', + rest: s, + }; + }; +} + +function sequence( + combinators: Combinator[], + reduce: (values: Capture[]) => Capture = v => v[0] +): Combinator { + return (s: string) => { + let rest = s; + let value = ''; + const captures: Capture[] = []; + + for (const c of combinators) { + const result = c(rest); + if (!result.success) return { success: false }; + + rest = result.rest; + value += result.value; + if (result.capture) captures.push(result.capture); + } + + return { + success: true, + value, + rest, + capture: reduce(captures), + }; + }; +} + +function chain( + combinators: Combinator[], + reduce: (values: Capture[]) => Capture = v => v[0] +): Combinator { + const joined = combinators.flatMap((combinator, index) => + index === 0 ? [combinator] : [optional(whitespace()), combinator] + ); + return sequence(joined, reduce); +} + +function parens(combinator: Combinator): Combinator { + return chain([regex(/\(/), combinator, regex(/\)/)]); +} + +function greedy( + min: number, + combinator: Combinator, + reduce: (values: Capture[]) => Capture = v => v[v.length - 1], + separator?: Combinator +): Combinator { + return (s: string) => { + let rest = s; + let value = ''; + const captures: Capture[] = []; + + let count; + for (count = 0; ; count++) { + const result = combinator(rest); + if (!result.success) break; + rest = result.rest; + value += result.value; + if (result.capture) captures.push(result.capture); + + if (separator) { + const sepResult = separator(rest); + if (!sepResult.success) { + count++; // count as matched but no trailing separator + break; + } + rest = sepResult.rest; + value += sepResult.value; + } + } + + if (count < min) return { success: false }; + return { + success: true, + value, + rest, + capture: reduce(captures), + }; + }; +} + +function capture(combinator: Combinator, map?: (value: string) => Capture): Combinator { + return (s: string) => { + const result = combinator(s); + if (!result.success) return { success: false }; + return { + success: true, + value: result.value, + rest: result.rest, + capture: map ? map(result.value) : result.value, + }; + }; +} + +// CLARITY VALUE PARSERS +function clInt(): Combinator { + return capture(regex(/\-?[0-9]+/), v => Cl.int(parseInt(v))); +} + +function clUint(): Combinator { + return sequence([regex(/u/), capture(regex(/[0-9]+/), v => Cl.uint(parseInt(v)))]); +} + +function clBool(): Combinator { + return capture(regex(/true|false/), v => Cl.bool(v === 'true')); +} + +function clPrincipal(): Combinator { + return sequence([ + regex(/\'/), + capture( + sequence([regex(/[A-Z0-9]+/), optional(sequence([regex(/\./), regex(/[a-zA-Z0-9\-]+/)]))]), + Cl.address + ), + ]); +} + +function clBuffer(): Combinator { + return sequence([regex(/0x/), capture(regex(/[0-9a-fA-F]+/), Cl.bufferFromHex)]); +} + +/** @ignore helper for string values, removes escaping and unescapes special characters */ +function unescape(input: string): string { + return input.replace(/\\\\/g, '\\').replace(/\\(.)/g, '$1'); +} + +function clAscii(): Combinator { + return sequence([ + regex(/"/), + capture(regex(/(\\.|[^"])*/), t => Cl.stringAscii(unescape(t))), + regex(/"/), + ]); +} + +function clUtf8(): Combinator { + return sequence([ + regex(/u"/), + capture(regex(/(\\.|[^"])*/), t => Cl.stringUtf8(unescape(t))), + regex(/"/), + ]); +} + +function clList(): Combinator { + return parens( + sequence([ + regex(/list/), + greedy(0, sequence([whitespace(), clValue()]), c => Cl.list(c as ClarityValue[])), + ]) + ); +} + +function clTuple(): Combinator { + const tupleCurly = chain([ + regex(/\{/), + greedy( + 1, + // entries + sequence( + [ + capture(regex(/[a-zA-Z][a-zA-Z0-9_]*/)), // key + regex(/\s*\:/), + whitespace(), // todo: can this be optional? + clValue(), // value + ], + ([k, v]) => Cl.tuple({ [k as string]: v as ClarityValue }) + ), + c => Cl.tuple(Object.assign({}, ...c.map(t => (t as TupleCV).data))), + regex(/\s*\,\s*/) + ), + regex(/\}/), + ]); + const tupleFunction = parens( + sequence([ + optional(whitespace()), + regex(/tuple/), + whitespace(), + greedy( + 1, + parens( + // entries + sequence( + [ + optional(whitespace()), + capture(regex(/[a-zA-Z][a-zA-Z0-9_]*/)), // key + whitespace(), + clValue(), // value + optional(whitespace()), + ], + ([k, v]) => Cl.tuple({ [k as string]: v as ClarityValue }) + ) + ), + c => Cl.tuple(Object.assign({}, ...c.map(t => (t as TupleCV).data))), + whitespace() + ), + ]) + ); + return either([tupleCurly, tupleFunction]); +} + +function clNone(): Combinator { + return capture(regex(/none/), Cl.none); +} + +function clSome(): Combinator { + return parens( + sequence([regex(/some/), whitespace(), clValue()], c => Cl.some(c[0] as ClarityValue)) + ); +} + +function clOk(): Combinator { + return parens(sequence([regex(/ok/), whitespace(), clValue()], c => Cl.ok(c[0] as ClarityValue))); +} + +function clErr(): Combinator { + return parens( + sequence([regex(/err/), whitespace(), clValue()], c => Cl.error(c[0] as ClarityValue)) + ); +} + +function clValue(map: (combinator: Combinator) => Combinator = v => v) { + return either( + [ + clBuffer, + clAscii, + clUtf8, + clInt, + clUint, + clBool, + clPrincipal, + clList, + clTuple, + clNone, + clSome, + clOk, + clErr, + ] + .map(lazy) + .map(map) + ); +} + +/** + * Parse a piece of string text as Clarity value syntax. + * Supports all Clarity value types (primitives, sequences, composite types). + * + * @example + * ``` + * const repr = Cl.parse("u4"); + * const repr = Cl.parse(`"hello"`); + * const repr = Cl.parse('(tuple (a 1) (b 2))'); + * ``` + */ +export function parse(clarityValueString: string): ClarityValue { + const result = clValue(entire)(clarityValueString); + if (!result.success || !result.capture) throw 'Parse error'; // todo: we can add better error messages and add position tracking + return result.capture as ClarityValue; +} diff --git a/packages/transactions/src/clarity/prettyPrint.ts b/packages/transactions/src/clarity/prettyPrint.ts index 22ce4e672..adcace11e 100644 --- a/packages/transactions/src/clarity/prettyPrint.ts +++ b/packages/transactions/src/clarity/prettyPrint.ts @@ -110,8 +110,8 @@ function prettyPrintWithDepth(cv: ClarityValue, space = 0, depth: number): strin } /** - * @description format clarity values in clarity style strings - * with the ability to prettify the result with line break end space indentation + * Format clarity values in clarity style strings with the ability to prettify + * the result with line break end space indentation. * @param cv The Clarity Value to format * @param space The indentation size of the output string. There's no indentation and no line breaks if space = 0 * @example @@ -126,6 +126,9 @@ function prettyPrintWithDepth(cv: ClarityValue, space = 0, depth: number): strin * // } * ``` */ -export function prettyPrint(cv: ClarityValue, space = 0): string { +export function stringify(cv: ClarityValue, space = 0): string { return prettyPrintWithDepth(cv, space, 0); } + +/** @deprecated alias for {@link Cl.stringify} */ +export const prettyPrint = stringify; diff --git a/packages/transactions/tests/clarity.test.ts b/packages/transactions/tests/clarity.test.ts index c0d465037..91ffb34df 100644 --- a/packages/transactions/tests/clarity.test.ts +++ b/packages/transactions/tests/clarity.test.ts @@ -6,40 +6,41 @@ import { hexToBytes, utf8ToBytes, } from '@stacks/common'; +import assert from 'assert'; +import { Cl } from '../src'; import { BytesReader } from '../src/bytesReader'; import { - bufferCV, BufferCV, ClarityType, ClarityValue, + IntCV, + ListCV, + SomeCV, + StandardPrincipalCV, + StringAsciiCV, + StringUtf8CV, + TupleCV, + UIntCV, + bufferCV, contractPrincipalCV, contractPrincipalCVFromStandard, deserializeCV, falseCV, - IntCV, intCV, listCV, - ListCV, noneCV, responseErrorCV, responseOkCV, serializeCV, someCV, - SomeCV, standardPrincipalCV, - StandardPrincipalCV, standardPrincipalCVFromAddress, stringAsciiCV, - StringAsciiCV, stringUtf8CV, - StringUtf8CV, trueCV, tupleCV, - TupleCV, uintCV, - UIntCV, } from '../src/clarity'; -import { Cl } from '../src'; import { cvToJSON, cvToString, @@ -47,9 +48,9 @@ import { getCVTypeString, isClarityType, } from '../src/clarity/clarityValue'; +import { parse } from '../src/clarity/parser'; import { addressToString } from '../src/common'; import { deserializeAddress } from '../src/types'; -import assert from 'assert'; const ADDRESS = 'SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B'; @@ -709,3 +710,70 @@ describe('Clarity Types', () => { }); }); }); + +const TEST_CASES_PARSER = [ + { input: '123', expected: Cl.int(123) }, + { input: '0', expected: Cl.int(0) }, + { input: '-15', expected: Cl.int(-15) }, + { input: 'u123', expected: Cl.uint(123) }, + { input: 'u0', expected: Cl.uint(0) }, + { input: 'true', expected: Cl.bool(true) }, + { input: 'false', expected: Cl.bool(false) }, + { + input: "'SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B", + expected: Cl.address('SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B'), + }, + { + input: "'SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B.some-contract", + expected: Cl.address('SP2JXKMSH007NPYAQHKJPQMAQYAD90NQGTVJVQ02B.some-contract'), + }, + { input: '0x68656c6c6f21', expected: Cl.bufferFromHex('68656c6c6f21') }, + { input: '"hello world"', expected: Cl.stringAscii('hello world') }, + { input: 'u"hello world"', expected: Cl.stringUtf8('hello world') }, + { input: '"hello \\"world\\""', expected: Cl.stringAscii('hello "world"') }, + { input: '(list 1 2 3)', expected: Cl.list([Cl.int(1), Cl.int(2), Cl.int(3)]) }, + { input: '( list 1 2 3 )', expected: Cl.list([Cl.int(1), Cl.int(2), Cl.int(3)]) }, + { input: '( list )', expected: Cl.list([]) }, + { input: '(list)', expected: Cl.list([]) }, + { + input: '{ id: u5, name: "clarity" }', + expected: Cl.tuple({ id: Cl.uint(5), name: Cl.stringAscii('clarity') }), + }, + { + input: '{something : (list 3 2 1),}', + expected: Cl.tuple({ something: Cl.list([Cl.int(3), Cl.int(2), Cl.int(1)]) }), + }, + { + input: '{ a: 0x68656c6c6f21 }', + expected: Cl.tuple({ a: Cl.bufferFromHex('68656c6c6f21') }), + }, + { + input: '( tuple ( something (list 1 2 3)) (other "other" ) )', + expected: Cl.tuple({ + something: Cl.list([Cl.int(1), Cl.int(2), Cl.int(3)]), + other: Cl.stringAscii('other'), + }), + }, + { input: 'none', expected: Cl.none() }, + { input: '( some u1 )', expected: Cl.some(Cl.uint(1)) }, + { input: '(some none)', expected: Cl.some(Cl.none()) }, + { input: '( ok true )', expected: Cl.ok(Cl.bool(true)) }, + { input: '(err false)', expected: Cl.error(Cl.bool(false)) }, + { + input: '( ok (list {id: 3} {id: 4} {id: 5} ))', + expected: Cl.ok( + Cl.list([ + Cl.tuple({ id: Cl.int(3) }), + Cl.tuple({ id: Cl.int(4) }), + Cl.tuple({ id: Cl.int(5) }), + ]) + ), + }, +] as const; + +test.each(TEST_CASES_PARSER)('clarity parser %p', ({ input, expected }) => { + const result = parse(input); + expect(result).toEqual(expected); +}); + +// const TEST_CASES_PARSER_THROW = []; // todo: e.g. `{}`