Skip to content

Commit

Permalink
Merge pull request #6 from b00ste/feat/internal-fn-docs
Browse files Browse the repository at this point in the history
feat: add support to parse and render Natspec from `internal` functions
  • Loading branch information
b00ste authored Jul 18, 2023
2 parents 72d0053 + cbfee1b commit e328a8c
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 54 deletions.
1 change: 1 addition & 0 deletions src/abiDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function decodeAbi(abi: AbiElement[]): Doc {
methods: {},
events: {},
errors: {},
internalMethods: {},
};

for (let i = 0; i < abi.length; i += 1) {
Expand Down
3 changes: 3 additions & 0 deletions src/dodocTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,7 @@ export interface Doc {
errors: {
[key: string]: Error;
};
internalMethods: {
[key: string]: Method;
};
}
235 changes: 181 additions & 54 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,32 +158,38 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo
}

/**
* @dev this function is intended to be used only to parse the `receive` and `fallback` function so far.
* Caution if using this function to parse the Natspec of other methods from the AST
* @dev Parse Natspec user and devdocs and write them into the `doc` object.
* This function is used in this Hardhat plugin only to parse:
* - the `receive` function.
* - the `fallback` function.
* - functions marked with the `internal` visibility.
*
* Caution if using this function to parse the Natspec of other part of the contract AST (e.g: struct, enum, modifier, etc...)
*/
const parseNatspecFromAST = (functionName: 'receive' | 'fallback', functionASTNode: any) => {
const parseNatspecFromAST = (functionSig: string, functionASTNode: any) => {
if ('documentation' in functionASTNode === false) return;

const tags = functionASTNode.documentation.text.split('@');

const docEntry = doc.methods[functionSig] || doc.internalMethods[functionSig];

tags.forEach((natspecTag: any) => {
if (natspecTag.replace(' ', '').length === 0) {
return;
}

if (natspecTag.startsWith('dev ')) {
doc.methods[`${functionName}()`].details = natspecTag.replace('dev ', '').trim();
docEntry.details = natspecTag.replace('dev ', '').trim();
}

if (natspecTag.startsWith('notice ')) {
doc.methods[`${functionName}()`].notice = natspecTag.replace('notice ', '').trim();
docEntry.notice = natspecTag.replace('notice ', '').trim();
}

// add custom any `@custom:` tags
if (natspecTag.startsWith('custom:')) {
const customTagName = natspecTag.substring('custom:'.length, natspecTag.trim().indexOf(' '));
doc.methods[`${functionName}()`][`custom:${customTagName}`] = natspecTag.replace(
`custom:${customTagName} `,
'',
);
docEntry[`custom:${customTagName}`] = natspecTag.replace(`custom:${customTagName} `, '');
}
});
};
Expand Down Expand Up @@ -211,66 +217,67 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo
doc.methods['fallback()'].code = newFallbackCode;
};

const parseParamsAndReturnNatspecsForFallback = (fallbackASTNode: any) => {
const paramDoc = fallbackASTNode.documentation.text
const parseParamsAndReturnNatspecFromAST = (
astNode: any,
functionName: string,
docEntry: 'methods' | 'internalMethods' | 'events' | 'errors',
) => {
if ('documentation' in astNode === false) return;

const paramDoc = astNode.documentation.text
.match(/@.*/g)
.filter((text: string) => text.match(/@param.*/));

if (paramDoc.length !== 0) {
const paramName = fallbackASTNode.parameters.parameters[0].name;
doc.methods['fallback()'].inputs[paramName] = {
type: 'bytes',
description: paramDoc[0].replace(`@param ${paramName} `, ''),
};
if (paramDoc.length > 0) {
astNode.parameters.parameters.forEach((param: any, index: number) => {
// Check if there is not the same number of Natspec @param tags compared
// to the number of params for the function in the AST node.
if (paramDoc[index] === undefined) return;

const paramName = param.name;
const paramType = param.typeDescriptions.typeString;

doc[docEntry][functionName].inputs[paramName] = {
type: paramType,
description: paramDoc[index].replace(`@param ${paramName} `, ''),
};
});
}

const returnDoc = fallbackASTNode.documentation.text
const returnDoc = astNode.documentation.text
.match(/@.*/g)
.filter((text: string) => text.match(/@return.*/));

if (returnDoc.length !== 0) {
const returnVariableName =
fallbackASTNode.returnParameters.parameters[0].name === ''
? ''
: fallbackASTNode.returnParameters.parameters[0].name;
// custom errors and events do not have return parameters
if (returnDoc.length > 0 && docEntry !== 'errors' && docEntry !== 'events') {
astNode.returnParameters.parameters.forEach((returnParam: any, index: number) => {
// Check if there is not the same number of Natspec @return tags compared
// to the number of return params for the function in the AST node.
if (returnDoc[index] === undefined) return;

doc.methods['fallback()'].outputs[returnVariableName] = {
type: 'bytes',
description: returnDoc[0].replace(`@return ${returnVariableName} `, ''),
};
}
};
const returnVariableName = returnParam.name === '' ? `_${index}` : returnParam.name;
const returnParamType = returnParam.typeDescriptions.typeString;

const parseNatspecFromFallback = (fallbackASTNode: any) => {
parseNatspecFromAST('fallback', fallbackASTNode);

// parse any @param or @return tags if fallback function is written as
// `fallback(bytes calldata fallbackParam) external <payable> returns (bytes memory)`
//
// Note: we should ideally have only a single `@param` or `@return` tag in this case
parseParamsAndReturnNatspecsForFallback(fallbackASTNode);

// modify the code if the fallback is written as `fallback(bytes calldata fallbackParam) external <payable> returns (bytes memory)`
if (
fallbackASTNode.parameters.parameters.length === 1 &&
fallbackASTNode.returnParameters.parameters.length === 1
) {
modifyFallbackFunctionSyntax(fallbackASTNode);
doc[docEntry][functionName].outputs[returnVariableName] = {
type: returnParamType,
description: returnDoc[index].replace(`@return ${returnVariableName} `, ''),
};
});
}
};

// Natspec docs from `receive()` and `fallback()` functions are not included in devdoc or userdoc
// Need to be fetched manually from AST
const AST = buildInfo?.output.sources[source].ast.nodes;

// find all AST nodes that are `contract`
// find the first AST node that is `contract`
const contractNode = AST.filter((node: any) => node.contractKind === 'contract')[0];

if (doc.methods['receive()'] !== undefined) {
const receiveASTNode = contractNode.nodes.find((node: any) => node.kind === 'receive');

if (receiveASTNode !== undefined && receiveASTNode.hasOwnProperty('documentation')) {
parseNatspecFromAST('receive', receiveASTNode);
parseNatspecFromAST('receive()', receiveASTNode);
} else {
// search in the parent contracts
// eslint-disable-next-line no-lonely-if
Expand All @@ -293,7 +300,7 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo
receiveParentASTNode !== undefined &&
receiveParentASTNode.hasOwnProperty('documentation')
) {
parseNatspecFromAST('receive', receiveParentASTNode);
parseNatspecFromAST('receive()', receiveParentASTNode);
// stop searching as soon as we find the most overriden function in the most derived contract
break;
}
Expand All @@ -306,10 +313,28 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo

if (doc.methods['fallback()'] !== undefined) {
// look for the `fallback()` function
const fallbackASTNode = contractNode.nodes.find((node: any) => node.kind === 'fallback');
const derivedFallbackASTNode = contractNode.nodes.find((node: any) => node.kind === 'fallback');

const parseNatspecFromFallback = (fallbackASTNode: any) => {
parseNatspecFromAST('fallback()', fallbackASTNode);

// parse any @param or @return tags if fallback function is written as
// `fallback(bytes calldata fallbackParam) external <payable> returns (bytes memory)`
//
// Note: we should ideally have only a single `@param` or `@return` tag in this case
parseParamsAndReturnNatspecFromAST(fallbackASTNode, 'fallback()', 'methods');

// modify the code if the fallback is written as `fallback(bytes calldata fallbackParam) external <payable> returns (bytes memory)`
if (
fallbackASTNode.parameters.parameters.length === 1 &&
fallbackASTNode.returnParameters.parameters.length === 1
) {
modifyFallbackFunctionSyntax(fallbackASTNode);
}
};

if (fallbackASTNode !== undefined && fallbackASTNode.hasOwnProperty('documentation')) {
parseNatspecFromFallback(fallbackASTNode);
if (derivedFallbackASTNode !== undefined && derivedFallbackASTNode.hasOwnProperty('documentation')) {
parseNatspecFromFallback(derivedFallbackASTNode);
} else {
// search in the parent contracts
// eslint-disable-next-line no-lonely-if, no-prototype-builtins
Expand All @@ -324,15 +349,15 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo
inheritedContractAST.length > 0 &&
baseContract.baseName.referencedDeclaration === inheritedContractAST[0].id
) {
const fallbackParentASTNode = inheritedContractAST[0].nodes.find(
const parentFallbackASTNode = inheritedContractAST[0].nodes.find(
(node: any) => node.kind === 'fallback',
);

if (
fallbackParentASTNode !== undefined &&
fallbackParentASTNode.hasOwnProperty('documentation')
parentFallbackASTNode !== undefined &&
parentFallbackASTNode.hasOwnProperty('documentation')
) {
parseNatspecFromFallback(fallbackParentASTNode);
parseNatspecFromFallback(parentFallbackASTNode);

// stop searching as soon as we find the most overriden function in the most derived contract
break;
Expand All @@ -344,6 +369,108 @@ async function generateDocumentation(hre: HardhatRuntimeEnvironment): Promise<vo
}
}

const parseNatspecOfInternalFunctionsFromAST = async (contract: any) => {
// Get Natspec of internal functions from AST
const internalFunctionsNodes = contract.nodes.filter(
(node: any) =>
node.kind === 'function' &&
node.nodeType === 'FunctionDefinition' &&
node.visibility === 'internal',
);

if (internalFunctionsNodes.length > 0) {
// create entries for internal functions in doc.internalMethods
internalFunctionsNodes.forEach((internalFunctionNode: any) => {
const {
name: functionName,
stateMutability,
parameters: { parameters: params } = { parameters: [] },
returnParameters: { parameters: returnParams } = { parameters: [] },
} = internalFunctionNode;

// this is non-standard, but our best attempt to create unique property name for each internal functions in the object
// there are no concept of function signatures and selector for internal functions
// (internal functions are not callable from outside the contract, and are of type 'function type)
// but we are using this way to store the natspec for each internal functions and differentiate them uniquely.
const internalFunctionSig = `${functionName}(${params
.map((param: any) => param.typeDescriptions.typeString)
.join(',')})`;

let internalFunctionCode = `function ${functionName}(${params
.map((param: any) => `${param.typeDescriptions.typeString} ${param.name}`)
.join(', ')}) internal`;

internalFunctionCode += ` ${stateMutability}`;

if (returnParams.length > 0) {
internalFunctionCode += ` returns (${returnParams
.map((returnParam: any) => {
let returnStatement = `${returnParam.typeDescriptions.typeString}`;
if (returnParam.name !== '') {
returnStatement += ` ${returnParam.name}`;
}
return returnStatement;
})
.join(', ')})`;
}

if (!doc.internalMethods) {
doc.internalMethods = {};
}

doc.internalMethods[internalFunctionSig] = {
code: internalFunctionCode,
inputs: {},
outputs: {},
};

parseNatspecFromAST(internalFunctionSig, internalFunctionNode);
parseParamsAndReturnNatspecFromAST(internalFunctionNode, internalFunctionSig, 'internalMethods');
});
}
};

const libraryNodes = AST.filter((node: any) => node.contractKind === 'library');

// library do not have inheritance, so we can parse the Natspec directly
libraryNodes.forEach((library: any) => {
parseNatspecOfInternalFunctionsFromAST(library);
});

// contract have inheritance, so we need to search for all the internal functions
// through the linearized inheritance graph,
// from the most base (parent) to the most derived (child) contract
const contractsNode = AST.filter((node: any) => node.contractKind === 'contract');

contractsNode.forEach((contract: any) => {
const { linearizedBaseContracts } = contract;

if (linearizedBaseContracts.length > 1) {
let ii = linearizedBaseContracts.length - 1;

while (ii >= 0) {
const contractId = linearizedBaseContracts[ii];

for (const sourceFile in buildInfo?.output.sources) {
const matchingASTNode = buildInfo?.output.sources[sourceFile].ast.nodes.find(
(node: any) => node.contractKind === 'contract' && node.id === contractId,
);

if (matchingASTNode !== undefined) {
parseNatspecOfInternalFunctionsFromAST(matchingASTNode);
}
}

ii--;
}
} else {
// parse directly if the contract does not inherit any other contract
parseNatspecOfInternalFunctionsFromAST(contract);
}
});

for (const methodSig in info.devdoc?.methods) {
const method = info.devdoc?.methods[methodSig];

Expand Down
47 changes: 47 additions & 0 deletions src/template.sqrl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,53 @@
{{@if (val['custom:info'])}}**Info:** *{{val['custom:info']}}*{{/if}}


{{@if (Object.keys(val.inputs).length > 0)}}
#### Parameters

| Name | Type | Description |
|---|---|---|
{{@foreach(val.inputs) => key, val}}
| {{key}} | {{val.type}} | {{val.description}} |
{{/foreach}}
{{/if}}

{{@if (Object.keys(val.outputs).length > 0)}}
#### Returns

| Name | Type | Description |
|---|---|---|
{{@foreach(val.outputs) => key, val}}
| {{key}} | {{val.type}} | {{val.description}} |
{{/foreach}}

{{/if}}
{{/foreach}}

{{/if}}

{{@if (Object.keys(it.internalMethods).length > 0)}}
## Internal Methods

{{@foreach(it.internalMethods) => key, val}}
### {{key.split('(')[0]}}


```solidity
{{val.code}}

```

{{@if (val.notice)}}{{val.notice}}{{/if}}

{{@if (val.details)}}*{{val.details}}*{{/if}}

{{@if (val['custom:requirement'])}}**Requirement:** *{{val['custom:requirement']}}*{{/if}}

{{@if (val['custom:danger'])}}**Danger:** *{{val['custom:danger']}}*{{/if}}

{{@if (val['custom:info'])}}**Info:** *{{val['custom:info']}}*{{/if}}


{{@if (Object.keys(val.inputs).length > 0)}}
#### Parameters

Expand Down

0 comments on commit e328a8c

Please sign in to comment.