Skip to content

Commit

Permalink
feat(all): Misti 0.4 support + Interactive project selection
Browse files Browse the repository at this point in the history
Closes #6
  • Loading branch information
jubnzv committed Oct 9, 2024
1 parent dd9e3e9 commit f7105d7
Show file tree
Hide file tree
Showing 7 changed files with 364 additions and 104 deletions.
2 changes: 2 additions & 0 deletions HACKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Building and testing locally

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"@ton/ton": ">=13.4.1"
},
"dependencies": {
"@nowarp/misti": "^0.3.1"
"@nowarp/misti": "~0.4.0"
},
"prettier": {
"semi": true,
Expand Down
117 changes: 117 additions & 0 deletions src/blueprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Various utilities to work with Blueprint internals and its generated files.
*
* @packageDocumentation
*/

import {
createSourceFile,
ScriptTarget,
Node,
isVariableDeclaration,
ObjectLiteralExpression,
isPropertyAssignment,
StringLiteral,
Expression,
forEachChild,
} from "typescript";
import { Args } from "@ton/blueprint";
import { promises as fs } from "fs";
import path from "path";

/**
* Tact project info parsed from the Blueprint compilation wrapper.
*/
export type TactProjectInfo = {
projectName: string;
target: string;
options: Record<string, unknown>;
};

/**
* Blueprint generates TypeScript wrappers that define compilation options in
* the following format:
*
* ```typescript
* import { CompilerConfig } from '@ton/blueprint';
* export const compile: CompilerConfig = {
* lang: 'tact',
* target: 'contracts/test1.tact',
* options: {
* debug: true,
* },
* };
* ```
*
* This function extracts the `target` and `options` values parsing the wrapper file.
*/
async function parseCompileWrapper(
filePath: string,
): Promise<TactProjectInfo | undefined> {
const projectName = path.basename(filePath).replace(".compile.ts", "");
const content = await fs.readFile(filePath, "utf-8");
const sourceFile = createSourceFile(
filePath,
content,
ScriptTarget.ESNext,
true,
);
let target: string | undefined;
let options: Record<string, unknown> = {} as Record<string, unknown>;
function findNodes(node: Node) {
if (isVariableDeclaration(node) && node.name.getText() === "compile") {
const initializer = node.initializer as ObjectLiteralExpression;
for (const property of initializer.properties) {
if (isPropertyAssignment(property)) {
if (property.name.getText() === "target") {
target = (property.initializer as StringLiteral).text;
}
if (property.name.getText() === "options") {
const optionsObj: Record<string, unknown> = {};
(
property.initializer as ObjectLiteralExpression
).properties.forEach((prop) => {
if (isPropertyAssignment(prop)) {
optionsObj[prop.name.getText()] = (
prop.initializer as Expression
).getText();
}
});
options = optionsObj;
}
}
}
}
forEachChild(node, findNodes);
}
findNodes(sourceFile);
return target ? { projectName, target, options } : undefined;
}

/**
* Extracts an information from the TypeScript wrapper file genreated by Blueprint.
*/
export async function extractProjectInfo(
blueprintCompilePath: string,
): Promise<TactProjectInfo | undefined> {
const filePath = path.resolve(__dirname, blueprintCompilePath);
console.log('Parsing', filePath);
return parseCompileWrapper(filePath);
}

/**
* Converts Blueprint arguments to a list of strings.
*/
export function argsToStringList(args: Args): string[] {
const argsList: string[] = args._;
Object.entries(args).forEach(([key, value]) => {
if (key !== "_" && value !== undefined) {
if (typeof value === "boolean") {
argsList.push(key);
} else {
argsList.push(key, value.toString());
}
}
});
return argsList;
}
135 changes: 135 additions & 0 deletions src/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Args, UIProvider } from "@ton/blueprint";
import { findCompiles, selectFile } from "@ton/blueprint/dist/utils";
import {
TactProjectInfo,
extractProjectInfo,
argsToStringList,
} from "./blueprint";
import {
MistiResult,
runMistiCommand,
createMistiCommand,
} from "@nowarp/misti/dist/cli";
import { setStdlibPath } from "./stdlibPaths";
import fs from "fs";
import path from "path";
import os from "os";

/**
* Interactively selects one of the Tact projects available in the Blueprint compile wrapper.
*/
async function selectProject(
ui: UIProvider,
args: Args,
): Promise<TactProjectInfo | undefined> {
const result = await selectFile(await findCompiles(), {
ui,
hint: args._.length > 1 && args._[1].length > 0 ? args._[1] : undefined,
import: false,
});
if (!fs.existsSync(result.path)) {
ui.write(
[
`❌ Cannot access ${result.path}`,
"Please specify path to your contract directly: `yarn blueprint misti path/to/contract.tact`",
].join("\n"),
);
return undefined;
}
const projectInfo = await extractProjectInfo(result.path);
if (projectInfo === undefined) {
ui.write(
[
`❌ Cannot extract project information from ${result.path}`,
"Please specify path to your contract directly: `yarn blueprint misti path/to/contract.tact`",
].join("\n"),
);
return undefined;
}
return projectInfo;
}

export class MistiExecutor {
private constructor(
private projectName: string,
private args: string[],
private ui: UIProvider,
) {}
public static async fromArgs(
args: Args,
ui: UIProvider,
): Promise<MistiExecutor | undefined> {
let argsStr = argsToStringList(args).slice(1);
const command = createMistiCommand();

let tactPathIsDefined = true;
const originalArgsStr = [...argsStr];
try {
await command.parseAsync(argsStr, { from: "user" });
} catch (error) {
tactPathIsDefined = false;
if (error instanceof Error && error.message.includes('is required')) {
const tempPath = '/tmp/contract.tact';
argsStr.push(tempPath);
await command.parseAsync(argsStr, { from: "user" });
} else {
throw error;
}
}
argsStr = originalArgsStr;

if (tactPathIsDefined) {
// The path to the Tact configuration or contract is explicitly specified
// in arguments (e.g. yarn blueprint misti path/to/contract.tact).
const tactPath = command.args[0];
const projectName = path.basename(tactPath).split(".")[0];
return new MistiExecutor(projectName, argsStr, ui);
}

// Interactively select the project
const project = await selectProject(ui, args);
if (!project) return undefined;
try {
const tactPath = this.generateTactConfig(project, ".");
argsStr.push(tactPath);
return new MistiExecutor(project.projectName, argsStr, ui);
} catch {
ui.write("❌ Cannot create a Tact config in current directory");
return undefined;
}
}

/**
* Generates the Tact configuration file based on the Blueprint compilation output.
*
* @param outDir Directory to save the generated file
* @throws If it is not possible to create a path
* @returns Absolute path to the generated config
*/
private static generateTactConfig(
config: TactProjectInfo,
outDir: string,
): string | never {
const content = JSON.stringify({
projects: [
{
name: config.projectName,
path: config.target,
output: path.join(os.tmpdir(), "tact-output"),
options: config.options,
},
],
});
const outPath = path.join(outDir, "tact.config.json");
fs.writeFileSync(outPath, content);
return outPath;
}

public async execute(): Promise<MistiResult> {
this.ui.write(`⏳ Checking ${this.projectName}...\n`);
setStdlibPath(this.args);
// ! is safe: it could not be undefined in Misti 0.4+
const result = (await runMistiCommand(this.args))!;
return result[1];
}
}
Loading

0 comments on commit f7105d7

Please sign in to comment.