UNPKG

@solarity/hardhat-markup

Version:

Customizable markdown smart contracts documentation

616 lines (507 loc) 20.1 kB
import { BuildInfo } from "hardhat/types"; import { ContractDefinition, EnumDefinition, ErrorDefinition, EventDefinition, FunctionDefinition, ModifierDefinition, ModifierInvocation, SourceLocation, SourceUnit, StructDefinition, VariableDeclaration, } from "solidity-ast"; import { Node } from "solidity-ast/node"; import { ASTDereferencer, astDereferencer, findAll, isNodeType } from "solidity-ast/utils"; import { CONSTANTS_BLOCK_NAME, DEFAULT_LICENSE, ENUMS_BLOCK_NAME, ERRORS_BLOCK_NAME, EVENTS_BLOCK_NAME, FUNCTIONS_BLOCK_NAME, MODIFIERS_BLOCK_NAME, STATE_VARIABLES_BLOCK_NAME, STRUCTS_BLOCK_NAME, } from "./constants"; import { ContractInfo, DocumentationBlock, NatSpecDocumentation } from "./types"; import pluginSolidity from "prettier-plugin-solidity"; import prettier = require("prettier"); export class Parser { private contractBuildInfo: BuildInfo; private deref: ASTDereferencer; constructor(contractBuildInfo: BuildInfo) { this.contractBuildInfo = contractBuildInfo; this.deref = astDereferencer(contractBuildInfo.output); } async parseContractInfo(source: string, name: string): Promise<ContractInfo> { const sourceUnit: SourceUnit = this.contractBuildInfo.output.sources[source].ast; const contractNode: ContractDefinition = sourceUnit.nodes.find( (node) => isNodeType("ContractDefinition", node) && node.name === name, ) as ContractDefinition; if (!contractNode) { throw new Error(`Contract ${name} not found in ${source}`); } const allFunctions: FunctionDefinition[] = [...findAll("FunctionDefinition", contractNode)]; let functions: FunctionDefinition[]; if (contractNode.contractKind === "library" && allFunctions.every((fn) => this.isPrivateOrInternal(fn))) { functions = allFunctions.filter((node) => this.isInternal(node)); } else { functions = allFunctions.filter((node) => this.isPublicOrExternal(node)); } return { name: contractNode.name, isAbstract: contractNode.abstract, contractKind: contractNode.contractKind, license: this.parseLicense(sourceUnit), documentations: await Promise.all([ this.parseDocumentation([contractNode], ""), this.parseDocumentation([...findAll("EnumDefinition", contractNode)], ENUMS_BLOCK_NAME), this.parseDocumentation([...findAll("StructDefinition", contractNode)], STRUCTS_BLOCK_NAME), this.parseDocumentation([...findAll("EventDefinition", contractNode)], EVENTS_BLOCK_NAME), this.parseDocumentation([...findAll("ErrorDefinition", contractNode)], ERRORS_BLOCK_NAME), this.parseDocumentation( [...findAll("VariableDeclaration", contractNode)].filter( (node) => node.constant && this.isPublicOrExternal(node), ), CONSTANTS_BLOCK_NAME, ), this.parseDocumentation( [...findAll("VariableDeclaration", contractNode)].filter( (node) => !node.constant && this.isPublicOrExternal(node), ), STATE_VARIABLES_BLOCK_NAME, ), this.parseDocumentation([...findAll("ModifierDefinition", contractNode)], MODIFIERS_BLOCK_NAME), this.parseDocumentation(functions, FUNCTIONS_BLOCK_NAME), ]), }; } isInternal(node: FunctionDefinition | VariableDeclaration): boolean { return node.visibility === "internal"; } isPrivateOrInternal(node: FunctionDefinition | VariableDeclaration): boolean { return node.visibility === "private" || this.isInternal(node); } isPublicOrExternal(node: FunctionDefinition | VariableDeclaration): boolean { return node.visibility === "public" || node.visibility === "external"; } parseSelector(node: FunctionDefinition | VariableDeclaration): string { return node.functionSelector ? ` (0x${node.functionSelector})` : ""; } async applyPrettier(text: string): Promise<string> { return (await prettier.format(text, { parser: "solidity-parse", plugins: [pluginSolidity] })).trim(); } parseHeader(node: any): string { switch (node.nodeType) { case "FunctionDefinition": { return `${node.name ? node.name : node.kind}${this.parseSelector(node)}`; } case "VariableDeclaration": { return `${node.name}${this.parseSelector(node)}`; } case "ContractDefinition": { return ""; } default: { return node.name; } } } async parseFullSign(node: any): Promise<string> { switch (node.nodeType) { case "FunctionDefinition": { return this.parseFullFunctionSign(node); } case "VariableDeclaration": { return this.parseFullStateVariableSign(node); } case "EventDefinition": { return this.parseFullEventSign(node); } case "ErrorDefinition": { return this.parseFullErrorSign(node); } case "EnumDefinition": { return this.parseFullEnumSign(node); } case "StructDefinition": { return this.parseFullStructSign(node); } case "ModifierDefinition": { return this.parseFullModifierSign(node); } case "ContractDefinition": { return this.parseFullContractSign(node); } default: { throw new Error(`Unknown node type ${node.nodeType}`); } } } async parseDocumentation(nodes: Node[], name: string): Promise<DocumentationBlock> { return { blockName: name, documentation: await Promise.all( nodes.map(async (node) => ({ fullSign: await this.parseFullSign(node), header: this.parseHeader(node), natSpecDocumentation: this.parseNatSpecDocumentation(node), })), ), }; } removeTypePrefix(typeString: string): string { if (typeString.includes("enum ")) { return typeString.replace("enum ", ""); } else if (typeString.includes("struct ")) { return typeString.replace("struct ", ""); } else if (typeString.includes("contract ")) { return typeString.replace("contract ", ""); } return typeString; } buildParameterString( parameters: VariableDeclaration[], delimiter: string = ", ", beginning: string = "", ending: string = "", ): string { return parameters .map((variableDeclaration) => { return `${beginning}${this.removeTypePrefix(variableDeclaration.typeDescriptions.typeString || "")}${ variableDeclaration.storageLocation === "default" ? "" : ` ${variableDeclaration.storageLocation}` }${variableDeclaration.indexed ? " indexed" : ""}${ variableDeclaration.name ? ` ${variableDeclaration.name}` : "" }${ending}`; }) .join(delimiter); } parseModifiersString(modifiers: ModifierInvocation[]): string { return modifiers .map( (modifier) => modifier.modifierName.name + (modifier.arguments ? `(${modifier.arguments .map((expression) => { // EXPERIMENTAL return this.parseStringFromSourceCode(expression.src); }) .join(", ")})` : ""), ) .join(" "); } async parseFullFunctionSign(functionDefinition: FunctionDefinition): Promise<string> { const kind = functionDefinition.kind; const functionName = functionDefinition.name.length === 0 ? "" : ` ${functionDefinition.name}`; const parameters = this.buildParameterString(functionDefinition.parameters.parameters); const visibility = kind === "constructor" ? "" : ` ${functionDefinition.visibility}`; const stateMutability = functionDefinition.stateMutability === "nonpayable" ? "" : ` ${functionDefinition.stateMutability}`; const modifiers = functionDefinition.modifiers.length === 0 ? "" : ` ${this.parseModifiersString(functionDefinition.modifiers)}`; const virtual = functionDefinition.virtual ? " virtual" : ""; const overrides = functionDefinition.overrides ? " override" : ""; const returns = functionDefinition.returnParameters.parameters.length === 0 ? "" : ` returns (${this.buildParameterString(functionDefinition.returnParameters.parameters)})`; const formattedRes = await this.applyPrettier( `${kind}${functionName}(${parameters})${visibility}${stateMutability}${modifiers}${virtual}${overrides}${returns};`, ); return formattedRes.substring(0, formattedRes.length - 1); } parseStringFromSourceCode(src: SourceLocation): string { const [start, end, id] = src.split(":").map((x) => parseInt(x)); const sourceFile = this.contractBuildInfo.input.sources[ Object.values(this.contractBuildInfo.output.sources).find((source) => source.id === id)?.ast.absolutePath ].content; return Buffer.from(sourceFile, "utf-8") .subarray(start, start + end) .toString(); } parseFullStateVariableSign(stateVariable: VariableDeclaration): string { let res = `${stateVariable.typeDescriptions.typeString}${ stateVariable.mutability === "mutable" ? "" : ` ${stateVariable.mutability}` } ${stateVariable.name}`; // EXPERIMENTAL if (stateVariable.value) { res += ` = ${this.parseStringFromSourceCode(stateVariable.value.src)}`; } return res; } parseFullEventSign(eventDefinition: EventDefinition): string { return `event ${eventDefinition.name}(${this.buildParameterString(eventDefinition.parameters.parameters)})${ eventDefinition.anonymous ? " anonymous" : "" }`; } parseFullErrorSign(errorDefinition: ErrorDefinition): string { return `error ${errorDefinition.name}(${this.buildParameterString(errorDefinition.parameters.parameters)})`; } parseFullStructSign(structDefinition: StructDefinition): string { return `struct ${structDefinition.name} {\n${this.buildParameterString( structDefinition.members, "\n", "\t", ";", )}\n}`; } parseFullEnumSign(enumDefinition: EnumDefinition): string { const parameters = enumDefinition.members.map((enumValue) => `\t ${enumValue.name}`).join(",\n"); return `enum ${enumDefinition.name} {\n${parameters}\n}`; } parseFullModifierSign(modifierDefinition: ModifierDefinition): string { return `modifier ${modifierDefinition.name}(${this.buildParameterString( modifierDefinition.parameters.parameters, )})`; } parseFullContractSign(contractDefinition: ContractDefinition): string { return `${contractDefinition.abstract ? "abstract " : ""}${contractDefinition.contractKind} ${ contractDefinition.name }${ contractDefinition.baseContracts.length === 0 ? "" : ` is ${contractDefinition.baseContracts.map((x) => x.baseName.name).join(", ")}` }`; } parseLicense(node: SourceUnit): string { return node.license || DEFAULT_LICENSE; } deleteCommentSymbols(text: string): string { let inCommentBlock = false; let startOfCommentBlock = false; let spacesToRemove = 0; return text .replace(/^\/\*\*([\s\S]*?)\*\/$/m, "$1") .trim() .replace(/^[ \t]*((\*{1,2}|\/{2,3}))+([ \t]*)(.*\n?)/gm, (match, p1, p2, p3, p4) => { if (p4.includes("```")) { inCommentBlock = !inCommentBlock; startOfCommentBlock = true; return p4; } if (inCommentBlock) { if (startOfCommentBlock) { spacesToRemove = p3.length; startOfCommentBlock = false; } return p3.substring(spacesToRemove) + p4; } return p4; }) .trim(); } replaceMultipleNewLinesWithOne(text: string): string { return text.replace(/\n{3,}/g, "\n"); } getValidParentNodeToInheritDocumentation( baseNode: FunctionDefinition | VariableDeclaration, ): FunctionDefinition | null { if (!baseNode.baseFunctions || baseNode.baseFunctions.length !== 1) { return null; } const parentNode = this.deref("FunctionDefinition", baseNode.baseFunctions[0]); if (isNodeType("VariableDeclaration", baseNode)) { return parentNode; } if (baseNode.parameters.parameters.length !== parentNode.parameters.parameters.length) { return null; } for (let i = 0; i < baseNode.parameters.parameters.length; i++) { if (baseNode.parameters.parameters[i].name !== parentNode.parameters.parameters[i].name) { return null; } } return parentNode; } findFunctionDefinitionByContractName( node: FunctionDefinition | VariableDeclaration, contractName: string, ): FunctionDefinition | undefined { const contract = this.deref("ContractDefinition", node.scope); if (contract.canonicalName === contractName || contract.name === contractName) { return node as FunctionDefinition; } if (!node.baseFunctions) { return undefined; } for (let i = 0; i < node.baseFunctions.length; i++) { const baseFunction = this.deref("FunctionDefinition", node.baseFunctions[i]); const result = this.findFunctionDefinitionByContractName(baseFunction, contractName); if (result) { return result; } } } findModifierDefinitionByContractName(node: ModifierDefinition, contractName: string): ModifierDefinition | undefined { // Since we cannot access the scope (Contract) in which the modifier is defined, // we are looking for the topmost parent modifier documentation without the @inheritdoc tag. const inheritDocsRegex = /@inheritdoc (\w+)/gm; const matches = inheritDocsRegex.exec(node.documentation?.text!); if (!matches) { return node as ModifierDefinition; } if (!node.baseModifiers) { return undefined; } for (let i = 0; i < node.baseModifiers.length; i++) { const baseModifier = this.deref("ModifierDefinition", node.baseModifiers[i]); const result = this.findModifierDefinitionByContractName(baseModifier, contractName); if (result) { return result; } } } parseNameAndDescription(text: string): [name: string, description: string] { const nameAndDescriptionRegex = /^(\w+).? ([\s\S]*)?/gm; const matches = nameAndDescriptionRegex.exec(text); if (!matches) { throw new Error(`Invalid name and description: ${text}`); } const [, name, description] = matches; return [name, description]; } joinDescriptionLines(text: string): string { return text.replace(/\n/g, " "); } parseNatSpecDocumentation(baseNode: any): NatSpecDocumentation { const natSpec: NatSpecDocumentation = {}; const nodes = [baseNode]; for (let i = 0; i < nodes.length; i++) { let node = nodes[i]; if (!node.documentation) { const parentNode = this.getValidParentNodeToInheritDocumentation(node); if (parentNode) { nodes.push(parentNode); } continue; } let sourceText: string = this.parseStringFromSourceCode(node.documentation.src); if (sourceText) { const text = this.deleteCommentSymbols(sourceText); const natSpecRegex = /^(?:@(\w+|custom:[a-z][a-z-]*) )?((?:(?!^@(?:\w+|custom:[a-z][a-z-]*) )[^])*)/gm; const matches = [...text.matchAll(natSpecRegex)]; for (let i = 0; i < matches.length; i++) { const [, tag = "notice", rawText] = matches[i]; const text = this.replaceMultipleNewLinesWithOne(rawText); switch (tag) { case "title": { break; } case "author": { if (!natSpec.author) { natSpec.author = text; } break; } case "notice": { if (!natSpec.notice) { natSpec.notice = text; } break; } case "dev": { if (!natSpec.dev) { natSpec.dev = []; } natSpec.dev.push(text); break; } case "param": { natSpec.params ??= []; const [paramName, paramDescriptionRaw] = this.parseNameAndDescription(text); const paramDescription = this.joinDescriptionLines(paramDescriptionRaw); // if tag is already defined, skip it if (natSpec.params.find((param) => param.name == paramName)) { continue; } let params: VariableDeclaration[]; if (node.nodeType === "EnumDefinition" || node.nodeType === "StructDefinition") { params = node.members; } else { params = node.parameters.parameters; } const variableDeclaration = params.find((param) => param.name == paramName); // so that tag was not found, and it may be a return tag if (!variableDeclaration) { // protection from infinite loop if (node.nodeType !== "EventDefinition") { matches[i][1] = "return"; i--; } break; } const type = variableDeclaration.typeDescriptions?.typeString || undefined; natSpec.params.push({ name: paramName, type: type, description: paramDescription }); break; } case "return": { // there is no return tag in events, so it is a param tag if (node.nodeType === "EventDefinition") { matches[i][1] = "param"; i--; break; } natSpec.returns ??= []; let currentParameter: VariableDeclaration = isNodeType("FunctionDefinition", node) ? node.returnParameters.parameters[natSpec.returns.length] : node; if (!currentParameter) { break; } const currentParameterName = currentParameter.name; const type = currentParameter.typeDescriptions?.typeString!; // if name is not defined for return parameter if (!currentParameterName) { natSpec.returns.push({ type: type, description: this.joinDescriptionLines(text) }); } else { const [paramName, paramDescriptionRaw] = this.parseNameAndDescription(text); const paramDescription = this.joinDescriptionLines(paramDescriptionRaw); if (paramName !== currentParameterName) { break; } natSpec.returns.push({ name: paramName, type: type, description: paramDescription }); } break; } case tag.startsWith("custom:") ? tag : "": { const customTag = tag.replace("custom:", ""); natSpec.custom ??= {}; natSpec.custom[customTag] = text; break; } case "inheritdoc": { const parentNodeRegex = /^(\w+)$/gm; const matches = parentNodeRegex.exec(text); if (!matches) { throw new Error(`Invalid inheritdoc tag: ${text}`); } const [, parentName] = matches; let parentNode; if (node.nodeType === "FunctionDefinition") { parentNode = this.findFunctionDefinitionByContractName(node, parentName); } else if (node.nodeType === "ModifierDefinition") { parentNode = this.findModifierDefinitionByContractName(node, parentName); } else if (node.nodeType === "VariableDeclaration") { break; } if (!parentNode) { throw new Error(`Invalid inheritdoc tag: ${text}`); } nodes.push(parentNode); break; } default: { throw new Error(`Unknown tag: ${tag}`); } } } } } return natSpec; } }