From f7105d79a69315245baff064e4ed58f005c8494f Mon Sep 17 00:00:00 2001 From: Georgiy Komarov Date: Wed, 9 Oct 2024 01:23:45 +0000 Subject: [PATCH] feat(all): Misti 0.4 support + Interactive project selection Closes #6 --- HACKING.md | 2 + package.json | 2 +- src/blueprint.ts | 117 +++++++++++++++++++++++++++++++++++++++ src/executor.ts | 135 +++++++++++++++++++++++++++++++++++++++++++++ src/misti.ts | 123 ++++++++--------------------------------- src/stdlibPaths.ts | 81 +++++++++++++++++++++++++++ yarn.lock | 8 +-- 7 files changed, 364 insertions(+), 104 deletions(-) create mode 100644 HACKING.md create mode 100644 src/blueprint.ts create mode 100644 src/executor.ts create mode 100644 src/stdlibPaths.ts diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..56e62a7 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,2 @@ +## Building and testing locally + diff --git a/package.json b/package.json index cf827b8..5c9b039 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@ton/ton": ">=13.4.1" }, "dependencies": { - "@nowarp/misti": "^0.3.1" + "@nowarp/misti": "~0.4.0" }, "prettier": { "semi": true, diff --git a/src/blueprint.ts b/src/blueprint.ts new file mode 100644 index 0000000..e5a7dd6 --- /dev/null +++ b/src/blueprint.ts @@ -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; +}; + +/** + * 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 { + 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 = {} as Record; + 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 = {}; + ( + 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 { + 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; +} diff --git a/src/executor.ts b/src/executor.ts new file mode 100644 index 0000000..b21ef1b --- /dev/null +++ b/src/executor.ts @@ -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 { + 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 { + 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 { + 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]; + } +} diff --git a/src/misti.ts b/src/misti.ts index 5891667..97b6bc8 100644 --- a/src/misti.ts +++ b/src/misti.ts @@ -1,110 +1,35 @@ import { Runner, Args, UIProvider } from "@ton/blueprint"; -import { runMistiCommand } from "@nowarp/misti/dist/cli"; -import path from "path"; -import fs from "fs"; - -export const STDLIB_PATH_ARG = "--tact-stdlib-path"; - -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; -} - -/** - * Returns true if there is an explicitly specified path to the Tact stdlib in the list of arguments. - */ -function hasStdlibPath(args: string[]): boolean { - return args.find((a) => a === STDLIB_PATH_ARG) !== undefined; -} - -/** - * Finds a directory which name starts from `prefix` inside `startPath`. - */ -function findDirectoryPath( - startPath: string, - prefix: string, -): string | undefined { - const files = fs.readdirSync(startPath); - for (const file of files) { - const filePath = path.join(startPath, file); - if (fs.statSync(filePath).isDirectory() && file.startsWith(prefix)) { - return path.relative(startPath, filePath); - } - } - return undefined; -} +import { MistiExecutor } from "./executor"; +import { MistiResult, resultToString } from "@nowarp/misti/dist/cli"; /** - * Finds the path to `stdlib.tact` in the `node_modules` of all the messed-up - * directory structures generated by any imaginable npm garbage. - * - * XXX: Touching paths below is not only dangerous, it should be considered illegal. + * Outputs the Misti result using the UI provider. */ -export function setTactStdlibPath(): string { - const stdlibPathElements = ["@tact-lang", "compiler", "stdlib"]; - let distPathPrefix = __dirname.includes("/dist/") - ? path.join("..", "..", "..", "..") - : path.join("..", "..", ".."); - - // pnpm (https://pnpm.io/) is another package manager which introduced a path - // structure different from yarn/npm. This hack bypasses it. - const pnpmDir = path.join( - path.resolve(__dirname, distPathPrefix), - "..", - "..", - ); - if (path.basename(pnpmDir).includes("pnpm")) { - const mistiDir = findDirectoryPath(pnpmDir, "@nowarp+misti"); - if (mistiDir !== undefined) { - distPathPrefix = path.join( - distPathPrefix, - "..", - "..", - mistiDir, - "node_modules", +function handleResult(result: MistiResult, ui: UIProvider): void { + const resultStr = resultToString(result, "plain"); + switch (result.kind) { + case "warnings": + ui.write( + `⚠️ Misti found ${result.warnings.reduce((acc, out) => acc + out.warnings.length, 0)} warnings:\n${resultStr}`, ); - } + break; + case "error": + ui.write(`❌ ${resultStr}`); + break; + case "ok": + ui.write(`✅ ${resultStr}`); + break; + case "tool": + ui.write(resultStr); + break; } - - return path.resolve(__dirname, distPathPrefix, ...stdlibPathElements); -} - -/** - * Adds STDLIB_PATH_ARG to the list of arguments if not set. - * - * This is required to use Tact's stdlib from the `node_modules` of the current - * blueprint project because it is not included in the `node_modules/@nowarp/misti`. - */ -function setStdlibPath(args: string[]): void { - if (hasStdlibPath(args)) return; - args.push(STDLIB_PATH_ARG); - args.push(setTactStdlibPath()); } export const misti: Runner = async (args: Args, ui: UIProvider) => { - ui.write("⏳ Checking the project...\n"); - const argsStr = argsToStringList(args).slice(1); - setStdlibPath(argsStr); - const result = (await runMistiCommand(argsStr))!; - if (result.warningsFound === 0 || result.output === undefined) { - if (result.error) { - // They are already printed to stderr by the driver - ui.write("⚠️ There are some problems executing Misti"); - } else { - ui.write("✅ No warnings found"); - } - } else { - ui.write( - `❌ Misti found ${result.warningsFound} warnings:\n${result.output}`, - ); + const executor = await MistiExecutor.fromArgs(args, ui); + if (!executor) { + return; } + const result = await executor.execute(); + handleResult(result, ui); }; diff --git a/src/stdlibPaths.ts b/src/stdlibPaths.ts new file mode 100644 index 0000000..b48fcab --- /dev/null +++ b/src/stdlibPaths.ts @@ -0,0 +1,81 @@ +/** + * Provides additional hacks to find paths to the Tact stdlib. + * These are necessary since we need to find them in node_modules, which has a different structure depending on the package manager. + * + * @packageDocumentation + */ +import path from "path"; +import fs from "fs"; + +const STDLIB_PATH_ARG = "--tact-stdlib-path"; + +/** + * Returns true if there is an explicitly specified path to the Tact stdlib in the list of arguments. + */ +function hasStdlibPath(args: string[]): boolean { + return args.find((a) => a === STDLIB_PATH_ARG) !== undefined; +} + +/** + * Finds a directory whose name starts with `prefix` inside `startPath`. + */ +function findDirectoryPath( + startPath: string, + prefix: string, +): string | undefined { + const files = fs.readdirSync(startPath); + for (const file of files) { + const filePath = path.join(startPath, file); + if (fs.statSync(filePath).isDirectory() && file.startsWith(prefix)) { + return path.relative(startPath, filePath); + } + } + return undefined; +} + +/** + * Finds the path to `stdlib.tact` in the `node_modules` of all the messed-up + * directory structures generated by any imaginable npm garbage. + * + * XXX: Touching paths below is not only dangerous; it should be considered illegal. + */ +function setTactStdlibPath(): string { + const stdlibPathElements = ["@tact-lang", "compiler", "stdlib"]; + let distPathPrefix = __dirname.includes("/dist/") + ? path.join("..", "..", "..", "..") + : path.join("..", "..", ".."); + + // pnpm (https://pnpm.io/) is another package manager which introduces a path + // structure different from yarn/npm. This hack bypasses it. + const pnpmDir = path.join( + path.resolve(__dirname, distPathPrefix), + "..", + "..", + ); + if (path.basename(pnpmDir).includes("pnpm")) { + const mistiDir = findDirectoryPath(pnpmDir, "@nowarp+misti"); + if (mistiDir !== undefined) { + distPathPrefix = path.join( + distPathPrefix, + "..", + "..", + mistiDir, + "node_modules", + ); + } + } + + return path.resolve(__dirname, distPathPrefix, ...stdlibPathElements); +} + +/** + * Adds STDLIB_PATH_ARG to the list of arguments if not set. + * + * This is required to use Tact's stdlib from the `node_modules` of the current + * blueprint project because it is not included in the `node_modules/@nowarp/misti`. + */ +export function setStdlibPath(args: string[]): void { + if (hasStdlibPath(args)) return; + args.push(STDLIB_PATH_ARG); + args.push(setTactStdlibPath()); +} diff --git a/yarn.lock b/yarn.lock index dda2921..7e05f02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -534,10 +534,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@nowarp/misti@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@nowarp/misti/-/misti-0.3.1.tgz#33a46743ac63d9f0cdf701fc5906d4eccef03bda" - integrity sha512-7D8ZXFn9vMNjj8bXKQg7nbhNuFsz9itqVtANnVF1u6lhx0hfmEc+soEHFSxm0pq2siiwqPEkbfIzQGZTJzdqyQ== +"@nowarp/misti@~0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@nowarp/misti/-/misti-0.4.0.tgz#427c04624dff1a91544fcdb48344290c1c1bcd80" + integrity sha512-YaeK25sTom5j0y6SJBom5vH1LJMWIerMV7OEL5trZhiSQ2W7PZ6CAAaKCEgXGXnPpUV2T1qK6S4aPQ6oTexIdQ== dependencies: "@nowarp/souffle" "^0.1.2" "@tact-lang/compiler" "~1.5.1"