UNPKG

@helios-lang/compiler

Version:

Helios is a Domain Specific Language that compiles to Plutus-Core (i.e. Cardano on-chain validator scripts). Helios is a non-Haskell alternative to Plutus. With this library you can compile Helios scripts and build Cardano transactions, all you need to bu

527 lines (460 loc) 13.7 kB
import assert, { strictEqual, throws } from "node:assert" import { it } from "node:test" import { bytesToHex, encodeUtf8 } from "@helios-lang/codec-utils" import { $ } from "@helios-lang/ir" import { isLeft, isRight, isString } from "@helios-lang/type-utils" import { makeByteArrayData, makeConstrData, makeIntData, makeListData, makeMapData, makeUplcDataValue } from "@helios-lang/uplc" import { Program } from "../src/program/Program.js" /** * @typedef {import("@helios-lang/codec-utils").BytesLike} BytesLike * @typedef {import("@helios-lang/uplc").CekResult} CekResult * @typedef {import("@helios-lang/uplc").UplcData} UplcData * @typedef {import("@helios-lang/uplc").UplcLogger} UplcLogger * @typedef {import("@helios-lang/uplc").UplcProgramV2} UplcProgramV2 */ /** * @typedef {{error: string} | UplcData | string} HeliosTestOutput */ /** * Runs the nested IR expression after emitting a trace message. If the * optional second arg contains an IR string expression, it is evaluated * and added to the trace message. * @example * $testTrace(`some Message`, $`...nestedIrExpression`) * $testTrace(`some Message`, $`optionalIrStringExpression`, $`...nestedIrExpression`) */ export function $testTrace(traceMessage, ...args) { const ir = args.pop() if (args.length > 1) { throw new Error( `only 2 or 3 args for $testTrace(message [String], [extraTraceExpr [String expr]], nestedIr), please` ) } let [extraTraceExpr] = args if (!process.env.HL_TEST_TRACE) { throw new Error( `use of $testTrace() should be limited to local troubleshooting using 'pnpm testing'` ) } let tmExpr = `" -- ${traceMessage.replace(/"/g, '\\"')}"` if (extraTraceExpr) { tmExpr = `__helios__string____add( ${tmExpr}, ${extraTraceExpr})` } const t = $`__core__trace(${tmExpr}, () -> { ${ir} })()` // console.log(t.toString()) return t } /** * @typedef {{ * description: string * main: string * modules?: string[] * inputs: UplcData[] * output: HeliosTestOutput * fails?: boolean | RegExp * }} HeliosTest */ /** * @typedef {{ * description: string * main: string * modules?: string[] * fails?: boolean * }} HeliosTypeTest */ /** * @typedef {( * (inputs: UplcData[], output: HeliosTestOutput) => [ CekResult, CekResult | undefined ] * ) & { program: Program } * } RunnerFunction */ /** * @param {HeliosTest[]} testVector */ export function compileAndRunMany(testVector) { testVector.forEach((t) => compileAndRun(t)) } /** * Syntax errors should be tested elsewhere * @param {HeliosTest} test */ export function compileAndRun(test) { it(test.description, () => { /** * * @returns {[Program, UplcProgramV2]} */ const initialTest = () => { const program = new Program(test.main, { moduleSources: test.modules, isTestnet: true }) const uplc0 = program.compile(false) return [program, uplc0] } if (true == test.fails) { throws(initialTest) return } else if (test.fails) { // console.log("checking for failure message", test.fails) // NOTE: node's test library doesn't seem to correctly check for throwing with string input // ... despite claiming to do so. When it works, we can update the type of test.fails above // ... to include string. Meanwhile, pass a RegExp to check for the error message. throws(initialTest, test.fails) return } const [program, uplc0] = initialTest() const args = test.inputs.map((d) => makeUplcDataValue(d)) const result0 = uplc0.eval(args) resultEquals(result0, test.output) const hash0 = bytesToHex(uplc0.hash()) const uplc1 = program.compile(true) const hash1 = bytesToHex(uplc1.hash()) if (hash1 != hash0) { const result1 = uplc1.eval(args) resultEquals(result1, test.output) // also make sure the costs and size are smaller const size0 = uplc0.toCbor().length const size1 = uplc1.toCbor().length assert( size1 < size0, `optimization didn't improve size (!(${size1} < ${size0}))` ) const costMem0 = result0.cost.mem const costMem1 = result1.cost.mem assert( costMem1 < costMem0, `optimization didn't improve mem cost (!(${costMem1.toString()} < ${costMem0.toString()}))` ) const costCpu0 = result0.cost.cpu const costCpu1 = result1.cost.cpu assert( costCpu1 < costCpu0, `optimization didn't improve cpu cost (!(${costCpu1.toString()} < ${costCpu0.toString()}))` ) } }) } /** * Throws an error if the program isn't optimized to `(<n-args>) -> {()} * @param {string} main * @param {string} expected */ export function assertOptimizedAs(main, expected) { const actualUplc = new Program(main, { isTestnet: true }).compile(true) const expectedUplc = new Program(expected, { isTestnet: true }).compile(true) if (bytesToHex(actualUplc.toCbor()) == bytesToHex(expectedUplc.toCbor())) { return } else { // this will fail, but at least print a nice message strictEqual(actualUplc.toString(), expectedUplc.toString()) } } /** * @typedef {{ * moduleSources?: string[] * dumpIR?: boolean * dumpCostPrefix?: string * }} CompileForRunOptions */ /** * @param {string} mainSrc * @param {CompileForRunOptions} options * @returns { RunnerFunction } */ export function compileForRun(mainSrc, options = {}) { const program = new Program(mainSrc, { moduleSources: options.moduleSources ?? [], isTestnet: true }) if (options.dumpIR) { const ir = program.toIR({ dependsOnOwnHash: false, hashDependencies: {}, optimize: false }) console.log(ir.toString()) } const uplcUnopt = program.compile(false) const hashUnopt = bytesToHex(uplcUnopt.hash()) const uplcOptimized = program.compile(true) const hashOptimized = bytesToHex(uplcOptimized.hash()) /** * @type {RunnerFunction} */ const runner = (inputs, output) => { if (!output) throw new Error( "must specify arg2: a HeliosTestOutput result to test against for this run" ) const args = inputs.map((d) => makeUplcDataValue(d)) const result0 = uplcUnopt.eval(args) try { resultEquals(result0, output) } catch (e) { const failureLog = result0.logs.map((l) => `---> ${l}`).join("\n") + `\n---- ^^^ test failure log: ${program.name} (unoptimized) -----------\n` console.error(failureLog) e.NOTE = "See failure log above" throw e } if (hashOptimized != hashUnopt) { const result1 = uplcOptimized.eval(args) try { resultEquals(result1, output) } catch (e) { const failureLog = result1.logs.map((l) => `--->: > ${l}`).join("\n") + `\n---- ^^^ test failure log: ${program.name} (optimized) -----------\n` console.error(failureLog) e.NOTE = "See failure log above" throw e } // also make sure the costs and size are smaller const size0 = uplcUnopt.toCbor().length const size1 = uplcOptimized.toCbor().length assert( size1 < size0, `optimization didn't improve size (!(${size1} < ${size0}))` ) const costMem0 = result0.cost.mem const costMem1 = result1.cost.mem assert( costMem1 < costMem0, `optimization didn't improve mem cost (!(${costMem1.toString()} < ${costMem0.toString()}))` ) const costCpu0 = result0.cost.cpu const costCpu1 = result1.cost.cpu assert( costCpu1 < costCpu0, `optimization didn't improve cpu cost (!(${costCpu1.toString()} < ${costCpu0.toString()}))` ) if (options.dumpCostPrefix) { console.log( `Cost of ${options.dumpCostPrefix}: mem=${costMem1}, cpu=${costCpu1}, lovelace=${Number(costMem1) * 0.0577 + Number(costCpu1) * 0.0000721}` ) } return [result0, result1] } return [result0, undefined] } runner.program = program // expose for layering more functionality in contract-utils tests return runner } /** * @param {string} src * @param {UplcData[]} dataArgs * @returns {UplcData} */ export function evalSingle(src, dataArgs = []) { const program = new Program(src, { moduleSources: [], isTestnet: true }) const uplc = program.compile(false) const args = dataArgs.map((d) => makeUplcDataValue(d)) const res = uplc.eval(args) if (isRight(res.result)) { const resData = res.result.right if (!isString(resData) && resData.kind == "data") { return resData.value } } else { throw new Error(res.result.left.error ?? "unexpected") } throw new Error("unexpected") } /** * Throws an error if the syntax or the types are wrong * @param {HeliosTypeTest} test */ export function evalTypes(test) { it(test.description, () => { const construct = () => { new Program(test.main, { moduleSources: test.modules, isTestnet: true, throwCompilerErrors: true }) } if (true === test.fails) { throws(construct) } else if (test.fails) { throws(construct, test.fails) } else { construct() } }) } /** * * @param {HeliosTypeTest[]} tests */ export function evalTypesMany(tests) { tests.forEach((test) => evalTypes(test)) } /** * @param {boolean} b * @returns {UplcData} */ export function bool(b) { return makeConstrData(b ? 1 : 0, []) } export const False = bool(false) export const True = bool(true) /** * @param {BytesLike} mph * @param {BytesLike} name */ export function assetclass(mph, name) { return constr(0, bytes(mph), bytes(name)) } /** * * @param {BytesLike} bs * @returns {UplcData} */ export function bytes(bs) { return makeByteArrayData(bs) } /** * @param {UplcData} d * @returns {UplcData} */ export function cbor(d) { return makeByteArrayData(d.toCbor()) } /** * @param {number} tag * @param {...UplcData} fields * @returns {UplcData} */ export function constr(tag, ...fields) { return makeConstrData(tag, fields) } /** * @param {number | bigint} i * @returns {UplcData} */ export function int(i) { return makeIntData(i) } /** * @param {[UplcData, UplcData][]} pairs * @returns {UplcData} */ export function map(pairs) { return makeMapData(pairs) } /** * @param {...UplcData} d * @returns {UplcData} */ export function list(...d) { return makeListData(d) } /** * @param {UplcData} d * @returns {UplcData} */ export function mixedOther(d) { return makeConstrData(0, [d]) } /** * @param {UplcData} d * @returns {UplcData} */ export function mixedSpending(d) { return makeConstrData(1, [d]) } /** * @param {number} top * @param {number} bottom * @returns {UplcData} */ export function ratio(top, bottom) { if (bottom < 0) { return makeListData([makeIntData(-top), makeIntData(-bottom)]) } else { return makeListData([makeIntData(top), makeIntData(bottom)]) } } /** * @param {number} i * @returns {UplcData} */ export function real(i) { return makeIntData(Math.trunc(i * 1000000)) } /** * @param {string} s * @returns {UplcData} */ export function str(s) { return makeByteArrayData(encodeUtf8(s)) } /** * @param {CekResult} cekResult * @returns {string} */ function cekResultToString(cekResult) { const output = cekResult.result if (isLeft(output)) { console.error(output.left.error) return "error" } else { if (isString(output.right)) { return output.right } else if (output.right.kind == "data") { const str = output.right.value.toString() if (!str) { debugger // keep-debugger to help troubleshoot this wrong case output.right.value.toString() } return str } else { return output.right.toString() } } } /** * @param {HeliosTestOutput} result * @returns {string} */ function expectedResultToString(result) { if (!result) { debugger // keep-debugger to help troubleshoot this wrong case throw new Error( `can't convert ${result} to string for result comparison` ) } if (typeof result == "string") { return result } else if ("error" in result) { return "error" } else { return result.toString() } } /** * @param {CekResult} actual * @param {HeliosTestOutput} expected */ function resultEquals(actual, expected) { strictEqual(cekResultToString(actual), expectedResultToString(expected)) }