Skip to content

Commit

Permalink
feat(contract): add CompilerCli
Browse files Browse the repository at this point in the history
  • Loading branch information
davidyuk committed Dec 18, 2022
1 parent d659d33 commit de56cbe
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 31 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- run: sudo apt install -y erlang
- uses: actions/checkout@v2
with:
fetch-depth: 0
Expand Down
134 changes: 134 additions & 0 deletions src/contract/compiler/Cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { execFile } from 'child_process';
import { tmpdir } from 'os';
import { resolve, dirname, basename } from 'path';
import { mkdir, writeFile, rm } from 'fs/promises';
import { fileURLToPath } from 'url';
import CompilerBase, { Aci } from './Base';
import { Encoded } from '../../utils/encoder';
import { CompilerError, InternalError, UnsupportedVersionError } from '../../utils/errors';
import semverSatisfies from '../../utils/semver-satisfies';

const getPackagePath = (): string => {
const path = dirname(fileURLToPath(import.meta.url));
if (basename(path) === 'dist') return resolve(path, '..');
if (basename(path) === 'compiler') return resolve(path, '../../..');
throw new InternalError('Can\'t get package path');
};

/**
* A wrapper around aesophia_cli, available only in Node.js
* Assumes that `escript` is available in PATH.
*/
export default class CompilerCli extends CompilerBase {
#path: string;

#ensureCompatibleVersion: Promise<void>;

constructor(
compilerPath = resolve(getPackagePath(), './bin/aesophia_cli'),
{ ignoreVersion }: { ignoreVersion?: boolean } = {},
) {
super();
this.#path = compilerPath;
if (ignoreVersion !== true) {
this.#ensureCompatibleVersion = this.version().then((version) => {
const versions = [version, '7.0.1', '8.0.0'] as const;
if (!semverSatisfies(...versions)) throw new UnsupportedVersionError('compiler', ...versions);
});
}
}

async #run(...parameters: string[]): Promise<string> {
return new Promise((pResolve, pReject) => {
execFile('escript', [this.#path, ...parameters], (error, stdout, stderr) => {
if (error != null) pReject(error);
else if (stderr !== '') pReject(new CompilerError(stderr));
else pResolve(stdout);
});
});
}

static async #saveContractToTmpDir(
sourceCode: string,
fileSystem: Record<string, string> = {},
): Promise<string> {
const randomName = (): string => Math.random().toString(36).slice(2);
const path = resolve(tmpdir(), `aepp-sdk-js-${randomName()}`);
await mkdir(path);
const sourceCodePath = resolve(path, `${randomName()}.aes`);
await writeFile(sourceCodePath, sourceCode);
await Promise.all(Object.entries(fileSystem)
.map(async ([name, src]) => {
const p = resolve(path, name);
await mkdir(dirname(p), { recursive: true });
return writeFile(p, src);
}));
return sourceCodePath;
}

async compile(path: string): Promise<{
bytecode: Encoded.ContractBytearray;
aci: Aci;
}> {
await this.#ensureCompatibleVersion;
let bytecode;
let aciCli;
try {
[bytecode, aciCli] = await Promise.all([
this.#run(path),
this.#run('--create_json_aci', path).then((res) => JSON.parse(res)),
]);
} catch (error) {
throw new CompilerError(error.message);
}
const aci = {
encodedAci: aciCli[0],
externalEncodedAci: aciCli.slice(1),
};
return {
bytecode: bytecode.trimEnd() as Encoded.ContractBytearray,
aci: aci as Aci,
};
}

async compileBySourceCode(sourceCode: string, fileSystem?: Record<string, string>): Promise<{
bytecode: Encoded.ContractBytearray;
aci: Aci;
}> {
const tmp = await CompilerCli.#saveContractToTmpDir(sourceCode, fileSystem);
try {
return await this.compile(tmp);
} finally {
await rm(dirname(tmp), { recursive: true });
}
}

async validate(bytecode: Encoded.ContractBytearray, path: string): Promise<boolean> {
await this.#ensureCompatibleVersion;
try {
return (await this.#run(path, '--validate', bytecode)).includes('Validation successful.');
} catch (error) {
return false;
}
}

async validateBySourceCode(
bytecode: Encoded.ContractBytearray,
sourceCode: string,
fileSystem?: Record<string, string>,
): Promise<boolean> {
const tmp = await CompilerCli.#saveContractToTmpDir(sourceCode, fileSystem);
try {
return await this.validate(bytecode, tmp);
} finally {
await rm(dirname(tmp), { recursive: true });
}
}

async version(): Promise<string> {
const verMessage = await this.#run('--version');
const ver = verMessage.match(/Sophia compiler version ([\d.]+)\n/)?.[1];
if (ver == null) throw new CompilerError('Can\'t get compiler version');
return ver;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export { default as AccountLedger } from './account/Ledger';
export { default as AccountLedgerFactory } from './account/LedgerFactory';
export { default as CompilerBase } from './contract/compiler/Base';
export { default as CompilerHttp } from './contract/compiler/Http';
export { default as CompilerCli } from './contract/compiler/Cli';
export { default as getFileSystem } from './contract/compiler/getFileSystem';
export { default as Channel } from './channel/Contract';

Expand Down
83 changes: 53 additions & 30 deletions test/integration/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,44 @@ import { expect } from 'chai';
import { describe, it } from 'mocha';
import { readFile } from 'fs/promises';
import { compilerUrl, ignoreVersion } from '.';
import { CompilerError, CompilerHttp, getFileSystem } from '../../src';
import {
CompilerBase, CompilerHttp, CompilerCli, CompilerError, getFileSystem,
} from '../../src';
import { Encoded } from '../../src/utils/encoder';

describe('Sophia Compiler', () => {
const compiler = new CompilerHttp(compilerUrl, { ignoreVersion });
const testSourceCodePath = './test/integration/contracts/Includes.aes';
let testSourceCode: string;
let testFileSystem: Record<string, string>;
const testBytecode = 'cb_+QEGRgOg7BH1sCv+p2IrS0Pn3/i6AfE8lOGUuC71lLPn6mbUm9PAuNm4cv4AWolkAjcCBwcHFBQAAgD+RNZEHwA3ADcAGg6CPwEDP/5Nt4A5AjcCBwcHDAECDAEABAMRAFqJZP6SiyA2ADcBBwcMAwgMAQAEAxFNt4A5/pSgnxIANwF3BwwBAAQDEarAwob+qsDChgI3AXcHPgQAALhgLwYRAFqJZD0uU3VibGlicmFyeS5zdW0RRNZEHxFpbml0EU23gDkxLkxpYnJhcnkuc3VtEZKLIDYRdGVzdBGUoJ8SJWdldExlbmd0aBGqwMKGOS5TdHJpbmcubGVuZ3Rogi8AhTcuMC4xAGzn9fM=';
const testBytecode2 = 'cb_+GhGA6BgYgXqYB9ctBcQ8mJ0+we5OXhb9PpsSQWP2DhPx9obn8C4O57+RNZEHwA3ADcAGg6CPwEDP/6AeCCSADcBd3cBAQCYLwIRRNZEHxFpbml0EYB4IJIZZ2V0QXJngi8AhTcuMC4xAMXqWXc=';
function testCompiler(compiler: CompilerBase): void {
const inclSourceCodePath = './test/integration/contracts/Includes.aes';
let inclSourceCode: string;
let inclFileSystem: Record<string, string>;
const inclBytecode = 'cb_+QEGRgOg7BH1sCv+p2IrS0Pn3/i6AfE8lOGUuC71lLPn6mbUm9PAuNm4cv4AWolkAjcCBwcHFBQAAgD+RNZEHwA3ADcAGg6CPwEDP/5Nt4A5AjcCBwcHDAECDAEABAMRAFqJZP6SiyA2ADcBBwcMAwgMAQAEAxFNt4A5/pSgnxIANwF3BwwBAAQDEarAwob+qsDChgI3AXcHPgQAALhgLwYRAFqJZD0uU3VibGlicmFyeS5zdW0RRNZEHxFpbml0EU23gDkxLkxpYnJhcnkuc3VtEZKLIDYRdGVzdBGUoJ8SJWdldExlbmd0aBGqwMKGOS5TdHJpbmcubGVuZ3Rogi8AhTcuMC4xAGzn9fM=';
// TODO: use Includes.aes after fixing https://github.com/aeternity/aesophia_cli/issues/74
const incSourceCodePath = './test/integration/contracts/Increment.aes';
let incSourceCode: string;
const incBytecode = 'cb_+G1GA6Cln3BxyOo1iNITGseMS58ZfBbRNB0x8Ix7Bh54qZlSOcC4QKD+Er1R0wA3AQcHFDQAAgD+RNZEHwA3ADcAGg6CPwEDP5svAhESvVHTJWluY3JlbWVudBFE1kQfEWluaXSCLwCFNy4wLjEAfImpuQ==';
const testBytecode = 'cb_+GhGA6BgYgXqYB9ctBcQ8mJ0+we5OXhb9PpsSQWP2DhPx9obn8C4O57+RNZEHwA3ADcAGg6CPwEDP/6AeCCSADcBd3cBAQCYLwIRRNZEHxFpbml0EYB4IJIZZ2V0QXJngi8AhTcuMC4xAMXqWXc=';

before(async () => {
testSourceCode = await readFile(testSourceCodePath, 'utf8');
testFileSystem = await getFileSystem(testSourceCodePath);
inclSourceCode = await readFile(inclSourceCodePath, 'utf8');
inclFileSystem = await getFileSystem(inclSourceCodePath);
incSourceCode = await readFile(incSourceCodePath, 'utf8');
});

it('returns version', async () => {
expect(await compiler.version()).to.be.equal('7.0.1');
});

it('compiles and generates aci by path', async () => {
const { bytecode, aci } = await compiler.compile(testSourceCodePath);
expect(bytecode).to.equal(testBytecode);
const { bytecode, aci } = await compiler.compile(inclSourceCodePath);
expect(bytecode).to.equal(inclBytecode);
expect(aci).to.have.property('encodedAci');
expect(aci).to.have.property('externalEncodedAci');
expect(aci).to.have.property('interface');
});

it('compiles and generates aci by source code', async () => {
const { bytecode, aci } = await compiler.compileBySourceCode(testSourceCode, testFileSystem);
expect(bytecode).to.equal(testBytecode);
const { bytecode, aci } = await compiler.compileBySourceCode(inclSourceCode, inclFileSystem);
expect(bytecode).to.equal(inclBytecode);
expect(aci).to.have.property('encodedAci');
expect(aci).to.have.property('externalEncodedAci');
expect(aci).to.have.property('interface');
});

it('throws clear exception if compile broken contract', async () => {
Expand All @@ -62,32 +66,39 @@ describe('Sophia Compiler', () => {
+ ' entrypoint getArg1(x : int) = baz\n',
)).to.be.rejectedWith(
CompilerError,
'compile error:\n'
+ 'type_error:3:3: Duplicate definitions of `getArg` at\n'
+ ' - line 2, column 3\n'
+ ' - line 3, column 3\n'
+ 'type_error:3:32: Unbound variable `baz`\n'
+ 'type_error:4:33: Unbound variable `baz`',
compiler instanceof CompilerCli
? /Command failed: escript [\w-/]+\/bin\/aesophia_cli( --create_json_aci)? [\w-/]+\.aes\nType error( in '[\w-/]+\.aes')? at line 3, col 3:\nDuplicate definitions of `getArg` at\n {2}- line 2, column 3\n {2}- line 3, column 3\n\n/m
: 'compile error:\n'
+ 'type_error:3:3: Duplicate definitions of `getArg` at\n'
+ ' - line 2, column 3\n'
+ ' - line 3, column 3\n'
+ 'type_error:3:32: Unbound variable `baz`\n'
+ 'type_error:4:33: Unbound variable `baz`',
);
});

it('validates bytecode by path', async () => {
expect(await compiler.validate(testBytecode, testSourceCodePath))
expect(await compiler.validate(incBytecode, incSourceCodePath))
.to.be.equal(true);
expect(await compiler.validate(testBytecode2, testSourceCodePath)).to.be.equal(false);
const invalidBytecode = `${testBytecode2}test` as Encoded.ContractBytearray;
expect(await compiler.validate(invalidBytecode, testSourceCodePath))
expect(await compiler.validate(testBytecode, incSourceCodePath)).to.be.equal(false);
const invalidBytecode = `${testBytecode}test` as Encoded.ContractBytearray;
expect(await compiler.validate(invalidBytecode, incSourceCodePath))
.to.be.equal(false);
});

it('validates bytecode by source code', async () => {
expect(await compiler.validateBySourceCode(testBytecode, testSourceCode, testFileSystem))
expect(await compiler.validateBySourceCode(incBytecode, incSourceCode))
.to.be.equal(true);
expect(await compiler.validateBySourceCode(testBytecode2, testSourceCode)).to.be.equal(false);
const invalidBytecode = `${testBytecode2}test` as Encoded.ContractBytearray;
expect(await compiler.validateBySourceCode(invalidBytecode, testSourceCode))
expect(await compiler.validateBySourceCode(testBytecode, incSourceCode)).to.be.equal(false);
const invalidBytecode = `${testBytecode}test` as Encoded.ContractBytearray;
expect(await compiler.validateBySourceCode(invalidBytecode, incSourceCode))
.to.be.equal(false);
});
}

describe('CompilerHttp', () => {
const compiler = new CompilerHttp(compilerUrl, { ignoreVersion });
testCompiler(compiler);

it('throws exception if used invalid compiler url', async () => {
const c = new CompilerHttp('https://compiler.aepps.comas');
Expand All @@ -109,3 +120,15 @@ describe('Sophia Compiler', () => {
});
});
});

describe('CompilerCli', () => {
const compiler = new CompilerCli();
testCompiler(compiler);

it('throws exception if used invalid compiler path', async () => {
const c = new CompilerCli('not-existing');
await expect(c.compileBySourceCode('test')).to.be.rejectedWith(
'Command failed: escript not-existing --version\nescript: Failed to open file: not-existing',
);
});
});
2 changes: 2 additions & 0 deletions test/integration/contracts/Increment.aes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
contract Increment =
entrypoint increment(x: int): int = x + 1
7 changes: 6 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ function configure(filename, opts = {}) {
},
resolve: {
extensions: ['.ts', '.js'],
fallback: {
fallback: opts.target.includes('browser') && {
buffer: require.resolve('buffer/'),
child_process: false,
os: false,
path: false,
'fs/promises': false,
url: false,
},
},
plugins: [
Expand Down

0 comments on commit de56cbe

Please sign in to comment.