Skip to content

Commit

Permalink
update gas report command
Browse files Browse the repository at this point in the history
  • Loading branch information
holic committed Jun 19, 2023
1 parent f8eb4db commit ac28fae
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 815 deletions.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"nice-grpc-web": "^2.0.1",
"openurl": "^1.1.1",
"path": "^0.12.7",
"strip-ansi": "^7.1.0",
"table": "^6.8.1",
"throttle-debounce": "^5.0.0",
"typechain": "^8.1.1",
Expand Down
146 changes: 58 additions & 88 deletions packages/cli/src/commands/gas-report.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { CommandModule } from "yargs";
import { readFileSync, writeFileSync, rmSync } from "fs";
import { readFileSync, writeFileSync } from "fs";
import { execa } from "execa";
import chalk from "chalk";
import { table, getBorderCharacters } from "table";
import stripAnsi from "strip-ansi";

/**
* Print the gas report to the console, save it to a file and compare it to a previous gas report if provided.
Expand Down Expand Up @@ -30,48 +31,35 @@ type Options = {
};

type GasReportEntry = {
source: string;
file: string;
test: string;
name: string;
functionCall: string;
gasUsed: number;
prevGasUsed?: number;
};

type GasReport = GasReportEntry[];

const tempFileSuffix = "MudGasReport";

const commandModule: CommandModule<Options, Options> = {
command: "gas-report",

describe: "Create a gas report",

builder(yargs) {
return yargs.options({
path: { type: "array", string: true, default: ["Gas.t.sol"], desc: "File containing the gas tests" },
save: { type: "string", desc: "Save the gas report to a file" },
compare: { type: "string", desc: "Compare to an existing gas report" },
});
},

async handler({ path: files, save, compare }) {
const validFiles = files.filter((file) => file.endsWith(".t.sol"));
const tempFiles = await Promise.all(validFiles.map((file) => createGasReport(file)));

process.once("SIGINT", () => {
console.log("caught sigint, deleting temp files");
tempFiles.forEach((file) => rmSync(file));
});

async handler({ save, compare }) {
let gasReport: GasReport;
try {
gasReport = await runGasReport();
} catch {
} catch (error) {
console.error(error);
setTimeout(() => process.exit());
return;
} finally {
// Delete the temporary files
tempFiles.forEach((file) => rmSync(file));
}

// If this gas report should be compared to an existing one, load the existing one
Expand All @@ -80,9 +68,7 @@ const commandModule: CommandModule<Options, Options> = {
const compareGasReport: GasReport = JSON.parse(readFileSync(compare, "utf8"));
// Merge the previous gas report with the new one
gasReport = gasReport.map((entry) => {
const prevEntry = compareGasReport.find(
(e) => e.name === entry.name && e.functionCall === entry.functionCall
);
const prevEntry = compareGasReport.find((e) => e.file === entry.file && e.name === entry.name);
return { ...entry, prevGasUsed: prevEntry?.gasUsed };
});
} catch {
Expand All @@ -103,88 +89,72 @@ const commandModule: CommandModule<Options, Options> = {

export default commandModule;

async function createGasReport(filename: string): Promise<string> {
console.log("Creating gas report for", chalk.bold(filename));

// Parse the given test file, and add gas reporting wherever requested by a `// !gasreport` comment
const fileContents = readFileSync(filename, "utf8");
let newFile = fileContents;

// Use a regex to find first line of each function
const functionRegex = new RegExp(/function (.*){/g);
// Insert a line to declare the _gasreport variable at the start of each function
let functionMatch;
while ((functionMatch = functionRegex.exec(fileContents)) !== null) {
const functionSignature = functionMatch[0];
newFile = newFile.replace(functionSignature, `${functionSignature}\nuint256 _gasreport;`);
}

// A gasreport comment has a name (written after the comment) and a function call (written on the next line)
// Create a regex to extract both the name and the function call
const regex = new RegExp(/\/\/ !gasreport (.*)\n(.*)/g);

// Apply the regex and loop through the matches,
// and create a new file with the gasreport comments replaced by the gas report
let match;
while ((match = regex.exec(fileContents)) !== null) {
const name = match[1];
const functionCall = match[2].trim();

newFile = newFile.replace(
match[0],
`
_gasreport = gasleft();
${functionCall}
_gasreport = _gasreport - gasleft();
console.log("GAS REPORT(${filename}): ${name} [${functionCall.replaceAll('"', '\\"')}]:", _gasreport);`
);
}

// Remove all occurrences of `pure` with `view`
newFile = newFile.replace(/pure/g, "view");

// Write the new file to disk (temporarily)
// Create the temp file by replacing the previous file name with MudGasReport
const tempFileName = filename.replace(/\.t\.sol$/, `${tempFileSuffix}.t.sol`);
writeFileSync(tempFileName, newFile);

return tempFileName;
}

async function runGasReport(): Promise<GasReport> {
console.log("Running gas report");
const gasReport: GasReport = [];

// Extract the logs from the child process
let logs = "";
let stdout: string;
try {
// Run the generated file using forge
const child = execa("forge", ["test", "--match-path", `*${tempFileSuffix}*`, "-vvv"], {
const child = execa("forge", ["test", "-vvv"], {
stdio: ["inherit", "pipe", "inherit"],
});
logs = (await child).stdout;
stdout = (await child).stdout;
} catch (error: any) {
console.log(error.stdout ?? error);
console.log(chalk.red("\n-----------\nError while running the gas report (see above)"));
throw error;
}

// Extract the gas reports from the logs
const lines = stdout.split("\n").map(stripAnsi);
const gasReportPattern = /^\s*GAS REPORT: (\d+) (.*)$/;
const testFunctionPattern = /^\[(?:PASS|FAIL).*\] (\w+)\(\)/;
const testFilePattern = /^Running \d+ tests? for (.*):(.*)$/;

function nearestLine(pattern: RegExp, startIndex: number = lines.length - 1): number {
for (let i = startIndex; i >= 0; i--) {
const line = lines[i];
if (pattern.test(line)) {
return i;
}
}
return -1;
}

for (let i = 0; i < lines.length; i++) {
const matches = lines[i].match(gasReportPattern);
if (!matches) continue;

const gasUsed = parseInt(matches[1]);
const name = matches[2];

const testFunctionLineIndex = nearestLine(testFunctionPattern, i);
if (testFunctionLineIndex === -1) {
throw new Error("Could not find nearest test function, did `forge test` output change?");
}
const testFileLineIndex = nearestLine(testFilePattern, testFunctionLineIndex);
if (testFileLineIndex === -1) {
throw new Error("Could not find nearest test filename, did `forge test` output change?");
}

const functionMatches = lines[testFunctionLineIndex].match(testFunctionPattern);
if (!functionMatches) {
throw new Error("Could not parse test function name, did `forge test` output change?");
}
const fileMatches = lines[testFileLineIndex].match(testFilePattern);
if (!fileMatches) {
throw new Error("Could not parse test filename, did `forge test` output change?");
}

const test = functionMatches[1];
const file = fileMatches[1];

// Create a regex to find all lines starting with `GAS REPORT:` and extract the name, function call and gas used
const gasReportRegex = new RegExp(/GAS REPORT\((.*)\): (.*) \[(.*)\]: (.*)/g);

// Loop through the matches and print the gas report
let gasReportMatch;
while ((gasReportMatch = gasReportRegex.exec(logs)) !== null) {
const source = gasReportMatch[1];
const name = gasReportMatch[2];
const functionCall = gasReportMatch[3].replace(";", "");
const gasUsed = parseInt(gasReportMatch[4]);
gasReport.push({ source, name, functionCall, gasUsed });
gasReport.push({ file, test, name, gasUsed });
}

gasReport.sort((a, b) => a.source.localeCompare(b.source));
gasReport.sort((a, b) => a.file.localeCompare(b.file));

return gasReport;
}
Expand All @@ -193,9 +163,9 @@ function printGasReport(gasReport: GasReport, compare?: string) {
if (compare) console.log(chalk.bold(`Gas report compared to ${compare}`));

const headers = [
chalk.bold("Source"),
chalk.bold("File"),
chalk.bold("Test"),
chalk.bold("Name"),
chalk.bold("Function call"),
chalk.bold("Gas used"),
...(compare ? [chalk.bold("Prev gas used"), chalk.bold("Difference")] : []),
];
Expand All @@ -205,7 +175,7 @@ function printGasReport(gasReport: GasReport, compare?: string) {
const diffEntry = diff > 0 ? chalk.red(`+${diff}`) : diff < 0 ? chalk.green(`${diff}`) : diff;
const compareColumns = compare ? [entry.prevGasUsed ?? "n/a", diffEntry] : [];
const gasUsedEntry = diff > 0 ? chalk.red(entry.gasUsed) : diff < 0 ? chalk.green(entry.gasUsed) : entry.gasUsed;
return [entry.source, entry.name, entry.functionCall, gasUsedEntry, ...compareColumns];
return [entry.file, entry.test, entry.name, gasUsedEntry, ...compareColumns];
});

const rows = [headers, ...values];
Expand Down
Loading

0 comments on commit ac28fae

Please sign in to comment.