From b7b457b076eaf4b1969e33c042a4b9098ad83177 Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Fri, 3 Jan 2020 22:59:18 -0500 Subject: [PATCH] Generate (complete) solidity docs (#2391) * `@0x/sol-doc`: New doc generator. * `@0x/sol-compiler`: Be more tolerant of AST-only compilation targets. * `@0x/contracts-exchange`: Add more devdoc comments. `@0x/contracts-exchange-libs`: Add more devdoc comments. * `@0x/sol-doc`: Update package script. * `@0x/sol-doc`: Remove unused files and update package scripts to be easier to configure. * Add more devdocs to contracts. * `@0x/sol-doc`: Remove doc artifacts. * `@0x/sol-doc`: Add `.gitignore` and `.npmignore`. * `@0x/contracts-exchange`: Fix compilation errors. * Fix more broken contracts. * `@0x/contracts-erc20-bridge-sampler`: Fix failing tests. * `@0x/contracts-asset-proxy`: Remove accidentally introduced hackathion file (lol). * `@0x/sol-doc`: Prevent some inherited contracts from being included in docs unintentionally. * `@0x/sol-doc`: Rename test file. * `@0x/contracts-exchange`: Update `orderEpoch` devdoc. * `@0x/sol-doc`: Tweak event and function docs. * Update CODEOWNERS. * `@0x/sol-doc` Tweak function md generation. * `@0x/sol-doc`: add `transformDocs()` tests. * `@0x/sol-doc`: add `extract_docs` tests. * `@0x/sol-doc` Fix linter errors. * `@0x/contracts-erc20-bridge-sampler`: Fix broken `ERC20BridgeSampler.sol` compile. * `@0x/sol-doc` Fix mismatched `dev-utils` dep version. * `@0x/sol-doc`: Add `gen_md` tests. * `@0x/sol-doc`: Remove `fs.promises` calls. * `@0x/sol-doc`: Fix linter errors. * `@0x/sol-doc`: Export all relevant types and functions. Co-authored-by: Lawrence Forman --- .../contracts/src/ERC20BridgeSampler.sol | 2 +- .../contracts/test/TestERC20BridgeSampler.sol | 7 +- .../erc20-bridge-sampler/src/artifacts.ts | 2 - .../erc20-bridge-sampler/src/wrappers.ts | 1 - contracts/erc20-bridge-sampler/tsconfig.json | 1 - .../contracts/src/LibEIP712ExchangeDomain.sol | 6 +- .../exchange-libs/contracts/src/LibOrder.sol | 4 +- contracts/exchange/contracts/src/Exchange.sol | 1 + .../src/MixinAssetProxyDispatcher.sol | 4 +- .../contracts/src/MixinExchangeCore.sol | 39 +- .../contracts/src/MixinProtocolFees.sol | 6 +- .../contracts/src/MixinSignatureValidator.sol | 10 +- .../contracts/src/MixinTransactions.sol | 17 +- .../contracts/src/MixinWrapperFunctions.sol | 21 +- .../contracts/test/TestWrapperFunctions.sol | 4 +- contracts/staking/compiler.json | 1 + .../staking/contracts/src/StakingProxy.sol | 1 + .../contracts/src/immutable/MixinStorage.sol | 23 +- .../contracts/src/interfaces/IStructs.sol | 14 +- .../contracts/src/stake/MixinStake.sol | 10 +- .../utils/contracts/src/Authorizable.sol | 6 + contracts/utils/contracts/src/LibEIP1271.sol | 3 +- contracts/utils/contracts/src/Ownable.sol | 4 + packages/sol-compiler/CHANGELOG.json | 9 + packages/sol-compiler/src/compiler.ts | 27 +- packages/sol-doc/.gitignore | 1 + packages/sol-doc/.npmignore | 10 + packages/sol-doc/CHANGELOG.json | 9 + packages/sol-doc/package.json | 20 +- packages/sol-doc/src/cli.ts | 92 ++- packages/sol-doc/src/extract_docs.ts | 638 ++++++++++++++++++ packages/sol-doc/src/gen_md.ts | 232 +++++++ packages/sol-doc/src/index.ts | 21 +- packages/sol-doc/src/sol_ast.ts | 231 +++++++ packages/sol-doc/src/sol_doc.ts | 505 -------------- packages/sol-doc/src/transform_docs.ts | 235 +++++++ packages/sol-doc/test/extract_docs_test.ts | 514 ++++++++++++++ .../contracts/MultipleReturnValues.sol | 7 - .../fixtures/contracts/NatspecEverything.sol | 40 -- .../contracts/StructParamAndReturn.sol | 18 - .../fixtures/contracts/TokenTransferProxy.sol | 115 ---- .../contracts/TokenTransferProxyNoDevdoc.sol | 100 --- packages/sol-doc/test/gen_md_test.ts | 114 ++++ packages/sol-doc/test/inputs/BaseContract.sol | 43 ++ .../sol-doc/test/inputs/InterfaceContract.sol | 14 + .../sol-doc/test/inputs/LibraryContract.sol | 22 + packages/sol-doc/test/inputs/TestContract.sol | 55 ++ .../test/solidity_doc_generator_test.ts | 273 -------- packages/sol-doc/test/transform_docs_test.ts | 225 ++++++ packages/sol-doc/test/util/chai_setup.ts | 13 - packages/sol-doc/test/utils/random_docs.ts | 175 +++++ 51 files changed, 2758 insertions(+), 1187 deletions(-) create mode 100644 packages/sol-doc/.gitignore create mode 100644 packages/sol-doc/.npmignore create mode 100644 packages/sol-doc/src/extract_docs.ts create mode 100644 packages/sol-doc/src/gen_md.ts create mode 100644 packages/sol-doc/src/sol_ast.ts delete mode 100644 packages/sol-doc/src/sol_doc.ts create mode 100644 packages/sol-doc/src/transform_docs.ts create mode 100644 packages/sol-doc/test/extract_docs_test.ts delete mode 100644 packages/sol-doc/test/fixtures/contracts/MultipleReturnValues.sol delete mode 100644 packages/sol-doc/test/fixtures/contracts/NatspecEverything.sol delete mode 100644 packages/sol-doc/test/fixtures/contracts/StructParamAndReturn.sol delete mode 100644 packages/sol-doc/test/fixtures/contracts/TokenTransferProxy.sol delete mode 100644 packages/sol-doc/test/fixtures/contracts/TokenTransferProxyNoDevdoc.sol create mode 100644 packages/sol-doc/test/gen_md_test.ts create mode 100644 packages/sol-doc/test/inputs/BaseContract.sol create mode 100644 packages/sol-doc/test/inputs/InterfaceContract.sol create mode 100644 packages/sol-doc/test/inputs/LibraryContract.sol create mode 100644 packages/sol-doc/test/inputs/TestContract.sol delete mode 100644 packages/sol-doc/test/solidity_doc_generator_test.ts create mode 100644 packages/sol-doc/test/transform_docs_test.ts delete mode 100644 packages/sol-doc/test/util/chai_setup.ts create mode 100644 packages/sol-doc/test/utils/random_docs.ts diff --git a/contracts/erc20-bridge-sampler/contracts/src/ERC20BridgeSampler.sol b/contracts/erc20-bridge-sampler/contracts/src/ERC20BridgeSampler.sol index 4b7525eb89..4a420f9295 100644 --- a/contracts/erc20-bridge-sampler/contracts/src/ERC20BridgeSampler.sol +++ b/contracts/erc20-bridge-sampler/contracts/src/ERC20BridgeSampler.sol @@ -147,7 +147,7 @@ contract ERC20BridgeSampler is ); // The fillable amount is zero if the order is not fillable or if the // signature is invalid. - if (orderInfo.orderStatus != uint8(LibOrder.OrderStatus.FILLABLE) || + if (orderInfo.orderStatus != LibOrder.OrderStatus.FILLABLE || !isValidSignature) { orderFillableTakerAssetAmounts[i] = 0; } else { diff --git a/contracts/erc20-bridge-sampler/contracts/test/TestERC20BridgeSampler.sol b/contracts/erc20-bridge-sampler/contracts/test/TestERC20BridgeSampler.sol index dbc7fa0ec7..4c98ca2c56 100644 --- a/contracts/erc20-bridge-sampler/contracts/test/TestERC20BridgeSampler.sol +++ b/contracts/erc20-bridge-sampler/contracts/test/TestERC20BridgeSampler.sol @@ -305,6 +305,8 @@ contract TestERC20BridgeSampler is TestERC20BridgeSamplerEth2Dai public eth2Dai; TestERC20BridgeSamplerKyberNetwork public kyber; + uint8 private constant MAX_ORDER_STATUS = uint8(LibOrder.OrderStatus.CANCELLED) + 1; + constructor() public { uniswap = new TestERC20BridgeSamplerUniswapExchangeFactory(); eth2Dai = new TestERC20BridgeSamplerEth2Dai(); @@ -336,9 +338,8 @@ contract TestERC20BridgeSampler is bytes32 orderHash = keccak256(abi.encode(order.salt)); // Everything else is derived from the hash. orderInfo.orderHash = orderHash; - orderInfo.orderStatus = uint8(uint256(orderHash) % uint8(-1)); - orderInfo.orderTakerAssetFilledAmount = - uint256(orderHash) % order.takerAssetAmount; + orderInfo.orderStatus = LibOrder.OrderStatus(uint256(orderHash) % MAX_ORDER_STATUS); + orderInfo.orderTakerAssetFilledAmount = uint256(orderHash) % order.takerAssetAmount; fillableTakerAssetAmount = order.takerAssetAmount - orderInfo.orderTakerAssetFilledAmount; isValidSignature = uint256(orderHash) % 2 == 1; diff --git a/contracts/erc20-bridge-sampler/src/artifacts.ts b/contracts/erc20-bridge-sampler/src/artifacts.ts index 28644f7523..41366d2e0e 100644 --- a/contracts/erc20-bridge-sampler/src/artifacts.ts +++ b/contracts/erc20-bridge-sampler/src/artifacts.ts @@ -7,9 +7,7 @@ import { ContractArtifact } from 'ethereum-types'; import * as ERC20BridgeSampler from '../generated-artifacts/ERC20BridgeSampler.json'; import * as IERC20BridgeSampler from '../generated-artifacts/IERC20BridgeSampler.json'; -import * as IKyberNetwork from '../generated-artifacts/IKyberNetwork.json'; export const artifacts = { ERC20BridgeSampler: ERC20BridgeSampler as ContractArtifact, IERC20BridgeSampler: IERC20BridgeSampler as ContractArtifact, - IKyberNetwork: IKyberNetwork as ContractArtifact, }; diff --git a/contracts/erc20-bridge-sampler/src/wrappers.ts b/contracts/erc20-bridge-sampler/src/wrappers.ts index 6928230134..d1921d6176 100644 --- a/contracts/erc20-bridge-sampler/src/wrappers.ts +++ b/contracts/erc20-bridge-sampler/src/wrappers.ts @@ -5,4 +5,3 @@ */ export * from '../generated-wrappers/erc20_bridge_sampler'; export * from '../generated-wrappers/i_erc20_bridge_sampler'; -export * from '../generated-wrappers/i_kyber_network'; diff --git a/contracts/erc20-bridge-sampler/tsconfig.json b/contracts/erc20-bridge-sampler/tsconfig.json index 1fd87a9f5b..866692f33f 100644 --- a/contracts/erc20-bridge-sampler/tsconfig.json +++ b/contracts/erc20-bridge-sampler/tsconfig.json @@ -5,7 +5,6 @@ "files": [ "generated-artifacts/ERC20BridgeSampler.json", "generated-artifacts/IERC20BridgeSampler.json", - "generated-artifacts/IKyberNetwork.json", "test/generated-artifacts/ERC20BridgeSampler.json", "test/generated-artifacts/IDevUtils.json", "test/generated-artifacts/IERC20BridgeSampler.json", diff --git a/contracts/exchange-libs/contracts/src/LibEIP712ExchangeDomain.sol b/contracts/exchange-libs/contracts/src/LibEIP712ExchangeDomain.sol index 551e9aa5ad..8e9628afdf 100644 --- a/contracts/exchange-libs/contracts/src/LibEIP712ExchangeDomain.sol +++ b/contracts/exchange-libs/contracts/src/LibEIP712ExchangeDomain.sol @@ -29,9 +29,11 @@ contract LibEIP712ExchangeDomain { // EIP712 Exchange Domain Version value string constant internal _EIP712_EXCHANGE_DOMAIN_VERSION = "3.0.0"; - // Hash of the EIP712 Domain Separator data - // solhint-disable-next-line var-name-mixedcase + // solhint-disable var-name-mixedcase + /// @dev Hash of the EIP712 Domain Separator data + /// @return 0 Domain hash. bytes32 public EIP712_EXCHANGE_DOMAIN_HASH; + // solhint-enable var-name-mixedcase /// @param chainId Chain ID of the network this contract is deployed on. /// @param verifyingContractAddressIfExists Address of the verifying contract (null if the address of this contract) diff --git a/contracts/exchange-libs/contracts/src/LibOrder.sol b/contracts/exchange-libs/contracts/src/LibOrder.sol index e257372c4b..d17a41d5b6 100644 --- a/contracts/exchange-libs/contracts/src/LibOrder.sol +++ b/contracts/exchange-libs/contracts/src/LibOrder.sol @@ -60,6 +60,7 @@ library LibOrder { } // solhint-disable max-line-length + /// @dev Canonical order structure. struct Order { address makerAddress; // Address that created the order. address takerAddress; // Address that is allowed to fill the order. If set to 0, any address is allowed to fill the order. @@ -78,8 +79,9 @@ library LibOrder { } // solhint-enable max-line-length + /// @dev Order information returned by `getOrderInfo()`. struct OrderInfo { - uint8 orderStatus; // Status that describes order's validity and fillability. + OrderStatus orderStatus; // Status that describes order's validity and fillability. bytes32 orderHash; // EIP712 typed data hash of the order (see LibOrder.getTypedDataHash). uint256 orderTakerAssetFilledAmount; // Amount of order that has already been filled. } diff --git a/contracts/exchange/contracts/src/Exchange.sol b/contracts/exchange/contracts/src/Exchange.sol index 3c58399783..8f5b8f0f89 100644 --- a/contracts/exchange/contracts/src/Exchange.sol +++ b/contracts/exchange/contracts/src/Exchange.sol @@ -29,6 +29,7 @@ import "./MixinTransferSimulator.sol"; // MixinAssetProxyDispatcher, MixinExchangeCore, MixinSignatureValidator, // and MixinTransactions are all inherited via the other Mixins that are // used. +/// @dev The 0x Exchange contract. contract Exchange is LibEIP712ExchangeDomain, MixinMatchOrders, diff --git a/contracts/exchange/contracts/src/MixinAssetProxyDispatcher.sol b/contracts/exchange/contracts/src/MixinAssetProxyDispatcher.sol index 84c9fe5ff5..2e93d5ef84 100644 --- a/contracts/exchange/contracts/src/MixinAssetProxyDispatcher.sol +++ b/contracts/exchange/contracts/src/MixinAssetProxyDispatcher.sol @@ -62,11 +62,11 @@ contract MixinAssetProxyDispatcher is /// @dev Gets an asset proxy. /// @param assetProxyId Id of the asset proxy. - /// @return The asset proxy registered to assetProxyId. Returns 0x0 if no proxy is registered. + /// @return assetProxy The asset proxy address registered to assetProxyId. Returns 0x0 if no proxy is registered. function getAssetProxy(bytes4 assetProxyId) external view - returns (address) + returns (address assetProxy) { return _assetProxies[assetProxyId]; } diff --git a/contracts/exchange/contracts/src/MixinExchangeCore.sol b/contracts/exchange/contracts/src/MixinExchangeCore.sol index 7ff964cbed..c5c1285e24 100644 --- a/contracts/exchange/contracts/src/MixinExchangeCore.sol +++ b/contracts/exchange/contracts/src/MixinExchangeCore.sol @@ -45,14 +45,21 @@ contract MixinExchangeCore is using LibSafeMath for uint256; using LibBytes for bytes; - // Mapping of orderHash => amount of takerAsset already bought by maker + /// @dev Mapping of orderHash => amount of takerAsset already bought by maker + /// @param 0 Order hash. + /// @return 0 The amount of taker asset filled. mapping (bytes32 => uint256) public filled; - // Mapping of orderHash => cancelled + /// @dev Mapping of orderHash => cancelled + /// @param 0 Order hash. + /// @return 0 Whether the order was cancelled. mapping (bytes32 => bool) public cancelled; - // Mapping of makerAddress => senderAddress => lowest salt an order can have in order to be fillable - // Orders with specified senderAddress and with a salt less than their epoch are considered cancelled + /// @dev Mapping of makerAddress => senderAddress => lowest salt an order can have in order to be fillable + /// Orders with specified senderAddress and with a salt less than their epoch are considered cancelled + /// @param 0 Address of the order's maker. + /// @param 1 Address of the order's sender. + /// @return 0 Minimum valid order epoch. mapping (address => mapping (address => uint256)) public orderEpoch; /// @dev Cancels all orders created by makerAddress with a salt less than or equal to the targetOrderEpoch @@ -94,7 +101,7 @@ contract MixinExchangeCore is /// @param order Order struct containing order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. /// @param signature Proof that order has been created by maker. - /// @return Amounts filled and fees paid by maker and taker. + /// @return fillResults Amounts filled and fees paid by maker and taker. function fillOrder( LibOrder.Order memory order, uint256 takerAssetFillAmount, @@ -125,7 +132,7 @@ contract MixinExchangeCore is /// @dev Gets information about an order: status, hash, and amount filled. /// @param order Order to gather information on. - /// @return OrderInfo Information about the order and its state. + /// @return orderInfo Information about the order and its state. /// See LibOrder.OrderInfo for a complete description. function getOrderInfo(LibOrder.Order memory order) public @@ -140,7 +147,7 @@ contract MixinExchangeCore is // edge cases in the supporting infrastructure because they have // an 'infinite' price when computed by a simple division. if (order.makerAssetAmount == 0) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.INVALID_MAKER_ASSET_AMOUNT); + orderInfo.orderStatus = LibOrder.OrderStatus.INVALID_MAKER_ASSET_AMOUNT; return orderInfo; } @@ -149,35 +156,35 @@ contract MixinExchangeCore is // Instead of distinguishing between unfilled and filled zero taker // amount orders, we choose not to support them. if (order.takerAssetAmount == 0) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.INVALID_TAKER_ASSET_AMOUNT); + orderInfo.orderStatus = LibOrder.OrderStatus.INVALID_TAKER_ASSET_AMOUNT; return orderInfo; } // Validate order availability if (orderInfo.orderTakerAssetFilledAmount >= order.takerAssetAmount) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.FULLY_FILLED); + orderInfo.orderStatus = LibOrder.OrderStatus.FULLY_FILLED; return orderInfo; } // Validate order expiration // solhint-disable-next-line not-rely-on-time if (block.timestamp >= order.expirationTimeSeconds) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.EXPIRED); + orderInfo.orderStatus = LibOrder.OrderStatus.EXPIRED; return orderInfo; } // Check if order has been cancelled if (cancelled[orderInfo.orderHash]) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.CANCELLED); + orderInfo.orderStatus = LibOrder.OrderStatus.CANCELLED; return orderInfo; } if (orderEpoch[order.makerAddress][order.senderAddress] > order.salt) { - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.CANCELLED); + orderInfo.orderStatus = LibOrder.OrderStatus.CANCELLED; return orderInfo; } // All other statuses are ruled out: order is Fillable - orderInfo.orderStatus = uint8(LibOrder.OrderStatus.FILLABLE); + orderInfo.orderStatus = LibOrder.OrderStatus.FILLABLE; return orderInfo; } @@ -185,7 +192,7 @@ contract MixinExchangeCore is /// @param order Order struct containing order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. /// @param signature Proof that order has been created by maker. - /// @return Amounts filled and fees paid by maker and taker. + /// @return fillResults Amounts filled and fees paid by maker and taker. function _fillOrder( LibOrder.Order memory order, uint256 takerAssetFillAmount, @@ -255,7 +262,7 @@ contract MixinExchangeCore is _assertValidCancel(order, orderInfo); // Noop if order is already unfillable - if (orderInfo.orderStatus != uint8(LibOrder.OrderStatus.FILLABLE)) { + if (orderInfo.orderStatus != LibOrder.OrderStatus.FILLABLE) { return; } @@ -337,7 +344,7 @@ contract MixinExchangeCore is view { // An order can only be filled if its status is FILLABLE. - if (orderInfo.orderStatus != uint8(LibOrder.OrderStatus.FILLABLE)) { + if (orderInfo.orderStatus != LibOrder.OrderStatus.FILLABLE) { LibRichErrors.rrevert(LibExchangeRichErrors.OrderStatusError( orderInfo.orderHash, LibOrder.OrderStatus(orderInfo.orderStatus) diff --git a/contracts/exchange/contracts/src/MixinProtocolFees.sol b/contracts/exchange/contracts/src/MixinProtocolFees.sol index c6dc31debf..cb96b3f1c3 100644 --- a/contracts/exchange/contracts/src/MixinProtocolFees.sol +++ b/contracts/exchange/contracts/src/MixinProtocolFees.sol @@ -29,10 +29,12 @@ contract MixinProtocolFees is IProtocolFees, Ownable { - // The protocol fee multiplier -- the owner can update this field. + /// @dev The protocol fee multiplier -- the owner can update this field. + /// @return 0 Gas multplier. uint256 public protocolFeeMultiplier; - // The address of the registered protocolFeeCollector contract -- the owner can update this field. + /// @dev The address of the registered protocolFeeCollector contract -- the owner can update this field. + /// @return 0 Contract to forward protocol fees to. address public protocolFeeCollector; /// @dev Allows the owner to update the protocol fee multiplier. diff --git a/contracts/exchange/contracts/src/MixinSignatureValidator.sol b/contracts/exchange/contracts/src/MixinSignatureValidator.sol index 39de699429..e9b1557c86 100644 --- a/contracts/exchange/contracts/src/MixinSignatureValidator.sol +++ b/contracts/exchange/contracts/src/MixinSignatureValidator.sol @@ -47,10 +47,16 @@ contract MixinSignatureValidator is // bytes4(keccak256("isValidWalletSignature(bytes32,address,bytes)")) bytes4 private constant LEGACY_WALLET_MAGIC_VALUE = 0xb0671381; - // Mapping of hash => signer => signed + /// @dev Mapping of hash => signer => signed + /// @param 0 Order hash. + /// @param 1 Signer address. + /// @return 0 Whether the hash is presigned. mapping (bytes32 => mapping (address => bool)) public preSigned; - // Mapping of signer => validator => approved + /// @dev Mapping of signer => validator => approved + /// @param 0 Signer address. + /// @param 1 Signature validator address. + /// @return 0 Whether the validator is allowed to validate on behalf of the signer. mapping (address => mapping (address => bool)) public allowedValidators; /// @dev Approves a hash on-chain. diff --git a/contracts/exchange/contracts/src/MixinTransactions.sol b/contracts/exchange/contracts/src/MixinTransactions.sol index 1803dd51e4..8c20f8216f 100644 --- a/contracts/exchange/contracts/src/MixinTransactions.sol +++ b/contracts/exchange/contracts/src/MixinTransactions.sol @@ -36,11 +36,14 @@ contract MixinTransactions is { using LibZeroExTransaction for LibZeroExTransaction.ZeroExTransaction; - // Mapping of transaction hash => executed - // This prevents transactions from being executed more than once. + /// @dev Mapping of transaction hash => executed + /// This prevents transactions from being executed more than once. + /// @param 0 The transaction hash. + /// @return 0 Whether the transation was executed. mapping (bytes32 => bool) public transactionsExecuted; - // Address of current transaction signer + /// @dev Address of current transaction signer. + /// @return 0 The address associated with the the current transaction. address public currentContextAddress; /// @dev Executes an Exchange method call in the context of signer. @@ -62,7 +65,7 @@ contract MixinTransactions is /// @dev Executes a batch of Exchange method calls in the context of signer(s). /// @param transactions Array of 0x transaction structures. /// @param signatures Array of proofs that transactions have been signed by signer(s). - /// @return Array containing ABI encoded return data for each of the underlying Exchange function calls. + /// @return returnData Array containing ABI encoded return data for each of the underlying Exchange function calls. function batchExecuteTransactions( LibZeroExTransaction.ZeroExTransaction[] memory transactions, bytes[] memory signatures @@ -70,10 +73,10 @@ contract MixinTransactions is public payable disableRefundUntilEnd - returns (bytes[] memory) + returns (bytes[] memory returnData) { uint256 length = transactions.length; - bytes[] memory returnData = new bytes[](length); + returnData = new bytes[](length); for (uint256 i = 0; i != length; i++) { returnData[i] = _executeTransaction(transactions[i], signatures[i]); } @@ -117,7 +120,7 @@ contract MixinTransactions is _setCurrentContextAddressIfRequired(signerAddress, address(0)); emit TransactionExecution(transactionHash); - + return returnData; } diff --git a/contracts/exchange/contracts/src/MixinWrapperFunctions.sol b/contracts/exchange/contracts/src/MixinWrapperFunctions.sol index 66ce4bd65f..a7ff4c51b1 100644 --- a/contracts/exchange/contracts/src/MixinWrapperFunctions.sol +++ b/contracts/exchange/contracts/src/MixinWrapperFunctions.sol @@ -36,10 +36,11 @@ contract MixinWrapperFunctions is { using LibSafeMath for uint256; - /// @dev Fills the input order. Reverts if exact takerAssetFillAmount not filled. + /// @dev Fills the input order. Reverts if exact `takerAssetFillAmount` not filled. /// @param order Order struct containing order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. /// @param signature Proof that order has been created by maker. + /// @return fillResults Amounts filled and fees paid. function fillOrKillOrder( LibOrder.Order memory order, uint256 takerAssetFillAmount, @@ -62,7 +63,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param takerAssetFillAmounts Array of desired amounts of takerAsset to sell in orders. /// @param signatures Proofs that orders have been created by makers. - /// @return Array of amounts filled and fees paid by makers and taker. + /// @return fillResults Array of amounts filled and fees paid by makers and taker. function batchFillOrders( LibOrder.Order[] memory orders, uint256[] memory takerAssetFillAmounts, @@ -89,7 +90,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param takerAssetFillAmounts Array of desired amounts of takerAsset to sell in orders. /// @param signatures Proofs that orders have been created by makers. - /// @return Array of amounts filled and fees paid by makers and taker. + /// @return fillResults Array of amounts filled and fees paid by makers and taker. function batchFillOrKillOrders( LibOrder.Order[] memory orders, uint256[] memory takerAssetFillAmounts, @@ -116,7 +117,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param takerAssetFillAmounts Array of desired amounts of takerAsset to sell in orders. /// @param signatures Proofs that orders have been created by makers. - /// @return Array of amounts filled and fees paid by makers and taker. + /// @return fillResults Array of amounts filled and fees paid by makers and taker. function batchFillOrdersNoThrow( LibOrder.Order[] memory orders, uint256[] memory takerAssetFillAmounts, @@ -145,7 +146,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. /// @param signatures Proofs that orders have been signed by makers. - /// @return Amounts filled and fees paid by makers and taker. + /// @return fillResults Amounts filled and fees paid by makers and taker. function marketSellOrdersNoThrow( LibOrder.Order[] memory orders, uint256 takerAssetFillAmount, @@ -186,7 +187,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param makerAssetFillAmount Desired amount of makerAsset to buy. /// @param signatures Proofs that orders have been signed by makers. - /// @return Amounts filled and fees paid by makers and taker. + /// @return fillResults Amounts filled and fees paid by makers and taker. function marketBuyOrdersNoThrow( LibOrder.Order[] memory orders, uint256 makerAssetFillAmount, @@ -234,7 +235,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param takerAssetFillAmount Minimum amount of takerAsset to sell. /// @param signatures Proofs that orders have been signed by makers. - /// @return Amounts filled and fees paid by makers and taker. + /// @return fillResults Amounts filled and fees paid by makers and taker. function marketSellOrdersFillOrKill( LibOrder.Order[] memory orders, uint256 takerAssetFillAmount, @@ -259,7 +260,7 @@ contract MixinWrapperFunctions is /// @param orders Array of order specifications. /// @param makerAssetFillAmount Minimum amount of makerAsset to buy. /// @param signatures Proofs that orders have been signed by makers. - /// @return Amounts filled and fees paid by makers and taker. + /// @return fillResults Amounts filled and fees paid by makers and taker. function marketBuyOrdersFillOrKill( LibOrder.Order[] memory orders, uint256 makerAssetFillAmount, @@ -295,7 +296,7 @@ contract MixinWrapperFunctions is /// @dev Fills the input order. Reverts if exact takerAssetFillAmount not filled. /// @param order Order struct containing order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. - /// @param signature Proof that order has been created by maker. + /// @param fillResults ignature Proof that order has been created by maker. function _fillOrKillOrder( LibOrder.Order memory order, uint256 takerAssetFillAmount, @@ -324,7 +325,7 @@ contract MixinWrapperFunctions is /// @param order Order struct containing order specifications. /// @param takerAssetFillAmount Desired amount of takerAsset to sell. /// @param signature Proof that order has been created by maker. - /// @return Amounts filled and fees paid by maker and taker. + /// @return fillResults Amounts filled and fees paid by maker and taker. function _fillOrderNoThrow( LibOrder.Order memory order, uint256 takerAssetFillAmount, diff --git a/contracts/exchange/contracts/test/TestWrapperFunctions.sol b/contracts/exchange/contracts/test/TestWrapperFunctions.sol index 20e0fae825..f1ec7e1e2f 100644 --- a/contracts/exchange/contracts/test/TestWrapperFunctions.sol +++ b/contracts/exchange/contracts/test/TestWrapperFunctions.sol @@ -30,7 +30,7 @@ import "../src/Exchange.sol"; contract TestWrapperFunctions is Exchange { - uint8 internal constant MAX_ORDER_STATUS = uint8(LibOrder.OrderStatus.CANCELLED); + LibOrder.OrderStatus internal constant MAX_ORDER_STATUS = LibOrder.OrderStatus.CANCELLED; uint256 internal constant ALWAYS_FAILING_SALT = uint256(-1); string internal constant ALWAYS_FAILING_SALT_REVERT_REASON = "ALWAYS_FAILING_SALT"; @@ -61,7 +61,7 @@ contract TestWrapperFunctions is // Lower uint128 of `order.salt` is the `orderTakerAssetFilledAmount`. orderInfo.orderTakerAssetFilledAmount = uint128(order.salt); // High byte of `order.salt` is the `orderStatus`. - orderInfo.orderStatus = uint8(order.salt >> 248) % (MAX_ORDER_STATUS + 1); + orderInfo.orderStatus = LibOrder.OrderStatus(uint8(order.salt >> 248) % (uint8(MAX_ORDER_STATUS) + 1)); orderInfo.orderHash = order.getTypedDataHash(EIP712_EXCHANGE_DOMAIN_HASH); } diff --git a/contracts/staking/compiler.json b/contracts/staking/compiler.json index 4487fe30d1..86353989a3 100644 --- a/contracts/staking/compiler.json +++ b/contracts/staking/compiler.json @@ -14,6 +14,7 @@ "*": { "*": [ "abi", + "devdoc", "evm.bytecode.object", "evm.bytecode.sourceMap", "evm.deployedBytecode.object", diff --git a/contracts/staking/contracts/src/StakingProxy.sol b/contracts/staking/contracts/src/StakingProxy.sol index fb963b37d1..5341790db2 100644 --- a/contracts/staking/contracts/src/StakingProxy.sol +++ b/contracts/staking/contracts/src/StakingProxy.sol @@ -26,6 +26,7 @@ import "./interfaces/IStorageInit.sol"; import "./interfaces/IStakingProxy.sol"; +/// #dev The 0x Staking contract. contract StakingProxy is IStakingProxy, MixinStorage, diff --git a/contracts/staking/contracts/src/immutable/MixinStorage.sol b/contracts/staking/contracts/src/immutable/MixinStorage.sol index 2154e143db..5e5f3b011f 100644 --- a/contracts/staking/contracts/src/immutable/MixinStorage.sol +++ b/contracts/staking/contracts/src/immutable/MixinStorage.sol @@ -51,19 +51,23 @@ contract MixinStorage is // tracking Pool Id, a unique identifier for each staking pool. bytes32 public lastPoolId; - // mapping from Maker Address to Pool Id of maker + /// @dev Mapping from Maker Address to pool Id of maker + /// @param 0 Maker address. + /// @return 0 The pool ID. mapping (address => bytes32) public poolIdByMaker; // mapping from Pool Id to Pool mapping (bytes32 => IStructs.Pool) internal _poolById; - // mapping from PoolId to balance of members + /// @dev mapping from pool ID to reward balance of members + /// @param 0 Pool ID. + /// @return 0 The total reward balance of members in this pool. mapping (bytes32 => uint256) public rewardsByPoolId; - // current epoch + // The current epoch. uint256 public currentEpoch; - // current epoch start time + // The current epoch start time. uint256 public currentEpochStartTimeInSeconds; // mapping from Pool Id to Epoch to Reward Ratio @@ -72,7 +76,9 @@ contract MixinStorage is // mapping from Pool Id to Epoch mapping (bytes32 => uint256) internal _cumulativeRewardsByPoolLastStored; - // registered 0x Exchange contracts + /// @dev Registered 0x Exchange contracts, capable of paying protocol fees. + /// @param 0 The address to check. + /// @return 0 Whether the address is a registered exchange. mapping (address => bool) public validExchanges; /* Tweakable parameters */ @@ -95,11 +101,16 @@ contract MixinStorage is /* State for finalization */ /// @dev Stats for each pool that generated fees with sufficient stake to earn rewards. - /// See `_minimumPoolStake` in MixinParams. + /// See `_minimumPoolStake` in `MixinParams`. + /// @param 0 Pool ID. + /// @param 1 Epoch number. + /// @return 0 Pool fee stats. mapping (bytes32 => mapping (uint256 => IStructs.PoolStats)) public poolStatsByEpoch; /// @dev Aggregated stats across all pools that generated fees with sufficient stake to earn rewards. /// See `_minimumPoolStake` in MixinParams. + /// @param 0 Epoch number. + /// @return 0 Reward computation stats. mapping (uint256 => IStructs.AggregatedStats) public aggregatedStatsByEpoch; /// @dev The WETH balance of this contract that is reserved for pool reward payouts. diff --git a/contracts/staking/contracts/src/interfaces/IStructs.sol b/contracts/staking/contracts/src/interfaces/IStructs.sol index f0ccc7ff62..7e8f4f2694 100644 --- a/contracts/staking/contracts/src/interfaces/IStructs.sol +++ b/contracts/staking/contracts/src/interfaces/IStructs.sol @@ -50,9 +50,9 @@ interface IStructs { /// @dev Encapsulates a balance for the current and next epochs. /// Note that these balances may be stale if the current epoch /// is greater than `currentEpoch`. - /// @param currentEpoch the current epoch - /// @param currentEpochBalance balance in the current epoch. - /// @param nextEpochBalance balance in `currentEpoch+1`. + /// @param currentEpoch The current epoch + /// @param currentEpochBalance Balance in the current epoch. + /// @param nextEpochBalance Balance in `currentEpoch+1`. struct StoredBalance { uint64 currentEpoch; uint96 currentEpochBalance; @@ -68,7 +68,7 @@ interface IStructs { } /// @dev Info used to describe a status. - /// @param status of the stake. + /// @param status Status of the stake. /// @param poolId Unique Id of pool. This is set when status=DELEGATED. struct StakeInfo { StakeStatus status; @@ -76,15 +76,15 @@ interface IStructs { } /// @dev Struct to represent a fraction. - /// @param numerator of fraction. - /// @param denominator of fraction. + /// @param numerator Numerator of fraction. + /// @param denominator Denominator of fraction. struct Fraction { uint256 numerator; uint256 denominator; } /// @dev Holds the metadata for a staking pool. - /// @param operator of the pool. + /// @param operator Operator of the pool. /// @param operatorShare Fraction of the total balance owned by the operator, in ppm. struct Pool { address operator; diff --git a/contracts/staking/contracts/src/stake/MixinStake.sol b/contracts/staking/contracts/src/stake/MixinStake.sol index b92b354e28..ad1ca1722c 100644 --- a/contracts/staking/contracts/src/stake/MixinStake.sol +++ b/contracts/staking/contracts/src/stake/MixinStake.sol @@ -31,7 +31,7 @@ contract MixinStake is /// @dev Stake ZRX tokens. Tokens are deposited into the ZRX Vault. /// Unstake to retrieve the ZRX. Stake is in the 'Active' status. - /// @param amount of ZRX to stake. + /// @param amount Amount of ZRX to stake. function stake(uint256 amount) external { @@ -56,7 +56,7 @@ contract MixinStake is /// @dev Unstake. Tokens are withdrawn from the ZRX Vault and returned to /// the staker. Stake must be in the 'undelegated' status in both the /// current and next epoch in order to be unstaked. - /// @param amount of ZRX to unstake. + /// @param amount Amount of ZRX to unstake. function unstake(uint256 amount) external { @@ -99,9 +99,9 @@ contract MixinStake is /// @dev Moves stake between statuses: 'undelegated' or 'delegated'. /// Delegated stake can also be moved between pools. /// This change comes into effect next epoch. - /// @param from status to move stake out of. - /// @param to status to move stake into. - /// @param amount of stake to move. + /// @param from Status to move stake out of. + /// @param to Status to move stake into. + /// @param amount Amount of stake to move. function moveStake( IStructs.StakeInfo calldata from, IStructs.StakeInfo calldata to, diff --git a/contracts/utils/contracts/src/Authorizable.sol b/contracts/utils/contracts/src/Authorizable.sol index e0c6fbfea0..2161bdb35b 100644 --- a/contracts/utils/contracts/src/Authorizable.sol +++ b/contracts/utils/contracts/src/Authorizable.sol @@ -35,7 +35,13 @@ contract Authorizable is _; } + /// @dev Whether an adderss is authorized to call privileged functions. + /// @param 0 Address to query. + /// @return 0 Whether the address is authorized. mapping (address => bool) public authorized; + /// @dev Whether an adderss is authorized to call privileged functions. + /// @param 0 Index of authorized address. + /// @return 0 Authorized address. address[] public authorities; /// @dev Initializes the `owner` address. diff --git a/contracts/utils/contracts/src/LibEIP1271.sol b/contracts/utils/contracts/src/LibEIP1271.sol index 0b8cbd2af8..6dfe8b7787 100644 --- a/contracts/utils/contracts/src/LibEIP1271.sol +++ b/contracts/utils/contracts/src/LibEIP1271.sol @@ -21,6 +21,7 @@ pragma solidity ^0.5.9; contract LibEIP1271 { - // Magic bytes returned by EIP1271 wallets on success. + /// @dev Magic bytes returned by EIP1271 wallets on success. + /// @return 0 Magic bytes. bytes4 constant public EIP1271_MAGIC_VALUE = 0x20c13b0b; } diff --git a/contracts/utils/contracts/src/Ownable.sol b/contracts/utils/contracts/src/Ownable.sol index 704648795b..eff9897552 100644 --- a/contracts/utils/contracts/src/Ownable.sol +++ b/contracts/utils/contracts/src/Ownable.sol @@ -26,6 +26,8 @@ import "./LibRichErrors.sol"; contract Ownable is IOwnable { + /// @dev The owner of this contract. + /// @return 0 The owner address. address public owner; constructor () @@ -39,6 +41,8 @@ contract Ownable is _; } + /// @dev Change the owner of this contract. + /// @param newOwner New owner address. function transferOwnership(address newOwner) public onlyOwner diff --git a/packages/sol-compiler/CHANGELOG.json b/packages/sol-compiler/CHANGELOG.json index 75c902386c..4b57ccd873 100644 --- a/packages/sol-compiler/CHANGELOG.json +++ b/packages/sol-compiler/CHANGELOG.json @@ -1,4 +1,13 @@ [ + { + "version": "4.0.3", + "changes": [ + { + "note": "More tolerant of AST-only compilation targets", + "pr": 2391 + } + ] + }, { "timestamp": 1576540892, "version": "4.0.2", diff --git a/packages/sol-compiler/src/compiler.ts b/packages/sol-compiler/src/compiler.ts index 0e770048d7..cb7a5f2a27 100644 --- a/packages/sol-compiler/src/compiler.ts +++ b/packages/sol-compiler/src/compiler.ts @@ -325,20 +325,21 @@ export class Compiler { for (const contractPath of input.contractsToCompile) { const contractName = contractPathToData[contractPath].contractName; - - const compiledContract = compilerOutput.contracts[contractPath][contractName]; - if (compiledContract === undefined) { - throw new Error( - `Contract ${contractName} not found in ${contractPath}. Please make sure your contract has the same name as it's file name`, - ); + if (compilerOutput.contracts[contractPath] !== undefined) { + const compiledContract = compilerOutput.contracts[contractPath][contractName]; + if (compiledContract === undefined) { + throw new Error( + `Contract ${contractName} not found in ${contractPath}. Please make sure your contract has the same name as it's file name`, + ); + } + if (this._shouldSaveStandardInput) { + await fsWrapper.writeFileAsync( + `${this._artifactsDir}/${contractName}.input.json`, + utils.stringifyWithFormatting(input.standardInput), + ); + } + addHexPrefixToContractBytecode(compiledContract); } - if (this._shouldSaveStandardInput) { - await fsWrapper.writeFileAsync( - `${this._artifactsDir}/${contractName}.input.json`, - utils.stringifyWithFormatting(input.standardInput), - ); - } - addHexPrefixToContractBytecode(compiledContract); if (shouldPersist) { await this._persistCompiledContractAsync( diff --git a/packages/sol-doc/.gitignore b/packages/sol-doc/.gitignore new file mode 100644 index 0000000000..2f88269126 --- /dev/null +++ b/packages/sol-doc/.gitignore @@ -0,0 +1 @@ +/docs diff --git a/packages/sol-doc/.npmignore b/packages/sol-doc/.npmignore new file mode 100644 index 0000000000..6335e95b36 --- /dev/null +++ b/packages/sol-doc/.npmignore @@ -0,0 +1,10 @@ +# Blacklist all files +.* +* +# Whitelist lib +!lib/**/* +# Blacklist tests and publish scripts +/lib/test/* +/lib/monorepo_scripts/ +# Package specific ignore +/docs diff --git a/packages/sol-doc/CHANGELOG.json b/packages/sol-doc/CHANGELOG.json index e11de40dcd..8909cafa0b 100644 --- a/packages/sol-doc/CHANGELOG.json +++ b/packages/sol-doc/CHANGELOG.json @@ -1,4 +1,13 @@ [ + { + "version": "3.1.0", + "changes": [ + { + "note": "Rewrite the whole thing to use custom AST walker.", + "pr": 2391 + } + ] + }, { "timestamp": 1576540892, "version": "3.0.2", diff --git a/packages/sol-doc/package.json b/packages/sol-doc/package.json index 0201dc51ea..74acdf6398 100644 --- a/packages/sol-doc/package.json +++ b/packages/sol-doc/package.json @@ -5,6 +5,7 @@ "main": "lib/src/index.js", "types": "lib/src/index.d.js", "scripts": { + "start": "node ./lib/src/cli.js", "build": "tsc", "build:ci": "yarn build", "test": "mocha --require source-map-support/register --require make-promises-safe lib/test/**/*_test.js --timeout 6000 --exit", @@ -13,11 +14,16 @@ "coverage:report:lcov": "nyc report --reporter=text-lcov > coverage/lcov.info", "lint": "tslint --format stylish --project .", "fix": "tslint --fix --format stylish --project .", - "clean": "shx rm -rf lib", - "generate-v1-protocol-docs": "(cd ../contracts/src/1.0.0; node ../../../../node_modules/.bin/sol-doc --contracts-dir . --contracts Exchange/Exchange_v1.sol TokenRegistry/TokenRegistry.sol TokenTransferProxy/TokenTransferProxy_v1.sol) > v1.0.0.json", - "generate-v2-protocol-docs": "(cd ../contracts/src/2.0.0; node ../../../../node_modules/.bin/sol-doc --contracts-dir . --contracts Exchange/Exchange.sol AssetProxy/ERC20Proxy.sol AssetProxy/ERC721Proxy.sol OrderValidator/OrderValidator.sol Forwarder/Forwarder.sol AssetProxyOwner/AssetProxyOwner.sol) > v2.0.0.json", - "deploy-v2-protocol-docs": "aws --profile 0xproject s3 cp --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --content-type application/json v2.0.0.json s3://staging-doc-jsons/contracts/", - "deploy-v1-protocol-docs": "aws --profile 0xproject s3 cp --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --content-type application/json v1.0.0.json s3://staging-doc-jsons/contracts/" + "clean": "shx rm -rf lib docs", + "generate-protocol-docs": "COMMIT=`git rev-parse --short HEAD`; mkdir -p ${npm_package_config_outputDir}; yarn start `echo ${npm_package_config_sources} | sed -r 's/(\\S+?)\\b/--source \\1/g'` --root ../../ --root ../../node_modules/@0x/contracts-=contracts/ `echo ${npm_package_config_contracts} | sed -r 's/(\\w+?)\\b/--contract \\1/g'` --md ${npm_package_config_outputDir}/reference.mdx --md-url-prefix \"${npm_package_config_repoBlobRoot}/${COMMIT}\"", + "s3:sync_md_docs": "aws s3 sync ${npm_package_config_outputDir} s3://docs-markdown/${npm_package_config_s3DocsPath} --profile 0xproject --region us-east-1 --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers" + }, + "config": { + "outputDir": "./docs", + "repoBlobRoot": "https://github.com/0xProject/0x-monorepo/blob", + "sources": "../../contracts/exchange/contracts/src/Exchange.sol ../../contracts/exchange-forwarder/contracts/src/Forwarder.sol ../../contracts/staking/contracts/src/Staking.sol ../../contracts/coordinator/contracts/src/Coordinator.sol", + "contracts": "Exchange Forwarder Staking Coordinator", + "s3DocsPath": "@0x/contracts-docs/v3.0.0" }, "bin": { "sol-doc": "bin/sol-doc.js" @@ -31,11 +37,13 @@ "@0x/utils": "^5.1.1", "ethereum-types": "^3.0.0", "ethereumjs-util": "^5.1.1", - "lodash": "^4.17.11", + "glob": "^7.1.2", "yargs": "^10.0.3" }, "devDependencies": { + "lodash": "^4.17.11", "@0x/tslint-config": "^4.0.0", + "@0x/dev-utils": "^3.0.2", "@types/mocha": "^5.2.7", "chai": "^4.0.1", "chai-as-promised": "^7.1.0", diff --git a/packages/sol-doc/src/cli.ts b/packages/sol-doc/src/cli.ts index 6da132d566..0f68a3d8a7 100644 --- a/packages/sol-doc/src/cli.ts +++ b/packages/sol-doc/src/cli.ts @@ -1,41 +1,83 @@ +import { logUtils } from '@0x/utils'; +import * as fs from 'fs'; +import * as glob from 'glob'; import 'source-map-support/register'; +import { promisify } from 'util'; import * as yargs from 'yargs'; -import { logUtils } from '@0x/utils'; +import { extractDocsAsync } from './extract_docs'; +import { generateMarkdownFromDocs } from './gen_md'; +import { transformDocs } from './transform_docs'; -import { SolDoc } from './sol_doc'; - -const JSON_TAB_WIDTH = 4; +const JSON_TAB_WIDTH = 2; (async () => { const argv = yargs - .option('contracts-dir', { + .option('source', { type: 'string', - description: 'path of contracts directory to compile', + array: true, + description: 'glob paths of source files to compile', + demandOption: true, }) - .option('contracts', { + .option('contract', { type: 'string', - description: 'comma separated list of contracts to compile', + array: true, + description: 'generate docs for only a contract', + }) + .option('complete', { + type: 'boolean', + description: 'generate docs for all contracts and private methods', + }) + .option('noFlatten', { + type: 'boolean', + description: 'do not merge inherited contracts', + }) + .option('json', { + type: 'string', + description: 'file to save JSON to', + }) + .option('root', { + type: 'string', + array: true, + description: 'rewrite paths as relative to these directory', + }) + .option('md', { + type: 'string', + description: 'file to save markdown to', + }) + .option('mdUrlPrefix', { + type: 'string', + description: 'prefix for markdown links', }) - .demandOption('contracts-dir') - .array('contracts') .help().argv; - // Unfortunately, the only way to currently retrieve the declared structs within Solidity contracts - // is to tease them out of the params/return values included in the ABI. These structures do - // not include the structs actual name, so we need a mapping to assign the proper name to a - // struct. If the name is not in this mapping, the structs name will default to the param/return value - // name (which mostly coincide). - const customTypeHashToName: { [hash: string]: string } = { - '52d4a768701076c7bac06e386e430883975eb398732eccba797fd09dd064a60e': 'Order', - '46f7e8c4d144d11a72ce5338458ea37b933500d7a65e740cbca6d16e350eaa48': 'FillResults', - c22239cf0d29df1e6cf1be54f21692a8c0b3a48b9367540d4ffff4608b331ce9: 'OrderInfo', - c21e9ff31a30941c22e1cb43752114bb467c34dea58947f98966c9030fc8e4a9: 'TraderInfo', - '6de3264a1040e027d4bdd29c71e963028238ac4ef060541078a7aced44a4d46f': 'MatchedFillResults', - }; - const solDoc = new SolDoc(); - const doc = await solDoc.generateSolDocAsync(argv.contractsDir, argv.contracts, customTypeHashToName); - process.stdout.write(JSON.stringify(doc, null, JSON_TAB_WIDTH)); + const sources = await getContractsAsync(argv.source); + if (!sources.length) { + throw new Error('no sources found'); + } + const docs = transformDocs(await extractDocsAsync(sources, argv.root), { + onlyExposed: !argv.complete, + flatten: !argv.noFlatten, + contracts: argv.contract, + }); + if (argv.json) { + await writeTextFileAsync(argv.json, JSON.stringify(docs, null, JSON_TAB_WIDTH)); + } + if (argv.md) { + await writeTextFileAsync(argv.md, generateMarkdownFromDocs(docs, { urlPrefix: argv.mdUrlPrefix })); + } })().catch(err => { logUtils.warn(err); process.exit(1); }); + +async function getContractsAsync(contractsGlobs: string[]): Promise { + let sources: string[] = []; + for (const g of contractsGlobs) { + sources = [...sources, ...(await promisify(glob)(g))]; + } + return sources; +} + +async function writeTextFileAsync(file: string, content: string): Promise { + return promisify(fs.writeFile)(file, content, { encoding: 'utf-8' }); +} diff --git a/packages/sol-doc/src/extract_docs.ts b/packages/sol-doc/src/extract_docs.ts new file mode 100644 index 0000000000..3b9b71ea26 --- /dev/null +++ b/packages/sol-doc/src/extract_docs.ts @@ -0,0 +1,638 @@ +import { Compiler } from '@0x/sol-compiler'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; + +import { + ArrayTypeNameNode, + AstNode, + ContractKind, + EnumValueNode, + FunctionKind, + isArrayTypeNameNode, + isContractDefinitionNode, + isEnumDefinitionNode, + isEventDefinitionNode, + isFunctionDefinitionNode, + isMappingTypeNameNode, + isSourceUnitNode, + isStructDefinitionNode, + isUserDefinedTypeNameNode, + isVariableDeclarationNode, + MappingTypeNameNode, + ParameterListNode, + SourceUnitNode, + splitAstNodeSrc, + StateMutability, + StorageLocation, + TypeNameNode, + VariableDeclarationNode, + Visibility, +} from './sol_ast'; + +export { ContractKind, FunctionKind, StateMutability, StorageLocation, Visibility } from './sol_ast'; + +export interface DocumentedItem { + doc: string; + line: number; + file: string; +} + +export interface EnumValueDocs extends DocumentedItem { + value: number; +} + +export interface ParamDocs extends DocumentedItem { + type: string; + indexed: boolean; + storageLocation: StorageLocation; + order: number; +} + +export interface ParamDocsMap { + [name: string]: ParamDocs; +} + +export interface EnumValueDocsMap { + [name: string]: EnumValueDocs; +} + +export interface MethodDocs extends DocumentedItem { + name: string; + contract: string; + stateMutability: string; + visibility: Visibility; + isAccessor: boolean; + kind: FunctionKind; + parameters: ParamDocsMap; + returns: ParamDocsMap; +} + +export interface EnumDocs extends DocumentedItem { + contract: string; + values: EnumValueDocsMap; +} + +export interface StructDocs extends DocumentedItem { + contract: string; + fields: ParamDocsMap; +} + +export interface EventDocs extends DocumentedItem { + contract: string; + name: string; + parameters: ParamDocsMap; +} + +export interface ContractDocs extends DocumentedItem { + kind: ContractKind; + inherits: string[]; + methods: MethodDocs[]; + events: EventDocs[]; + enums: { + [typeName: string]: EnumDocs; + }; + structs: { + [typeName: string]: StructDocs; + }; +} + +export interface SolidityDocs { + contracts: { + [typeName: string]: ContractDocs; + }; +} + +interface SolcOutput { + sources: { [file: string]: { id: number; ast: SourceUnitNode } }; + contracts: { + [file: string]: { + [contract: string]: { + metadata: string; + }; + }; + }; +} + +interface ContractMetadata { + sources: { [file: string]: { content: string } }; + settings: { remappings: string[] }; +} + +interface SourceData { + path: string; + content: string; +} + +interface Natspec { + comment: string; + dev: string; + params: { [name: string]: string }; + returns: { [name: string]: string }; +} + +/** + * Extract documentation, as JSON, from contract files. + */ +export async function extractDocsAsync(contractPaths: string[], roots: string[] = []): Promise { + const outputs = await compileAsync(contractPaths); + const sourceContents = (await Promise.all(outputs.map(getSourceContentsFromCompilerOutputAsync))).map(sources => + rewriteSourcePaths(sources, roots), + ); + const docs = createEmptyDocs(); + outputs.forEach((output, outputIdx) => { + for (const file of Object.keys(output.contracts)) { + const fileDocs = extractDocsFromFile( + output.sources[file].ast, + sourceContents[outputIdx][output.sources[file].id], + ); + mergeDocs(docs, fileDocs); + } + }); + return docs; +} + +async function compileAsync(files: string[]): Promise { + const compiler = new Compiler({ + contracts: files, + compilerSettings: { + outputSelection: { + '*': { + '*': ['metadata'], + '': ['ast'], + }, + }, + }, + }); + return (compiler.getCompilerOutputsAsync() as any) as Promise; +} + +async function getSourceContentsFromCompilerOutputAsync(output: SolcOutput): Promise { + const sources: SourceData[] = []; + for (const [importFile, fileOutput] of Object.entries(output.contracts)) { + if (importFile in sources) { + continue; + } + for (const contractOutput of Object.values(fileOutput)) { + const metadata = JSON.parse(contractOutput.metadata || '{}') as ContractMetadata; + let filePath = importFile; + if (!path.isAbsolute(filePath)) { + const { remappings } = metadata.settings; + let longestPrefix = ''; + let longestPrefixReplacement = ''; + for (const remapping of remappings) { + const [from, to] = remapping.substr(1).split('='); + if (longestPrefix.length < from.length) { + if (filePath.startsWith(from)) { + longestPrefix = from; + longestPrefixReplacement = to; + } + } + } + filePath = filePath.slice(longestPrefix.length); + filePath = path.join(longestPrefixReplacement, filePath); + } + const content = await promisify(fs.readFile)(filePath, { encoding: 'utf-8' }); + sources[output.sources[importFile].id] = { + path: path.relative('.', filePath), + content, + }; + } + } + return sources; +} + +function rewriteSourcePaths(sources: SourceData[], roots: string[]): SourceData[] { + const _roots = roots.map(root => root.split('=')); + return sources.map(s => { + let longestPrefix = ''; + let longestPrefixReplacement = ''; + for (const [from, to] of _roots) { + if (from.length > longestPrefix.length) { + if (s.path.startsWith(from)) { + longestPrefix = from; + longestPrefixReplacement = to || ''; + } + } + } + return { + ...s, + path: `${longestPrefixReplacement}${s.path.substr(longestPrefix.length)}`, + }; + }); +} + +function mergeDocs(dst: SolidityDocs, ...srcs: SolidityDocs[]): SolidityDocs { + if (srcs.length === 0) { + return dst; + } + for (const src of srcs) { + dst.contracts = { + ...dst.contracts, + ...src.contracts, + }; + } + return dst; +} + +function createEmptyDocs(): SolidityDocs { + return { contracts: {} }; +} + +function extractDocsFromFile(ast: SourceUnitNode, source: SourceData): SolidityDocs { + const HIDDEN_VISIBILITIES = [Visibility.Private, Visibility.Internal]; + const docs = createEmptyDocs(); + const visit = (node: AstNode, currentContractName?: string) => { + const { offset } = splitAstNodeSrc(node.src); + if (isSourceUnitNode(node)) { + for (const child of node.nodes) { + visit(child); + } + } else if (isContractDefinitionNode(node)) { + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[node.name] = { + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: natspec.dev || natspec.comment, + kind: node.contractKind, + inherits: node.baseContracts.map(c => normalizeType(c.baseName.typeDescriptions.typeString)), + methods: [], + events: [], + enums: {}, + structs: {}, + }; + for (const child of node.nodes) { + visit(child, node.name); + } + } else if (!currentContractName) { + return; + } else if (isVariableDeclarationNode(node)) { + if (HIDDEN_VISIBILITIES.includes(node.visibility)) { + return; + } + if (!node.stateVariable) { + return; + } + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[currentContractName].methods.push({ + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: getDocStringAround(source.content, offset), + name: node.name, + contract: currentContractName, + kind: FunctionKind.Function, + visibility: Visibility.External, + parameters: extractAcessorParameterDocs(node.typeName, natspec, source), + returns: extractAccesorReturnDocs(node.typeName, natspec, source), + stateMutability: StateMutability.View, + isAccessor: true, + }); + } else if (isFunctionDefinitionNode(node)) { + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[currentContractName].methods.push({ + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: natspec.dev || natspec.comment || getCommentsBefore(source.content, offset), + name: node.name, + contract: currentContractName, + kind: node.kind, + visibility: node.visibility, + parameters: extractFunctionParameterDocs(node.parameters, natspec, source), + returns: extractFunctionReturnDocs(node.returnParameters, natspec, source), + stateMutability: node.stateMutability, + isAccessor: false, + }); + } else if (isStructDefinitionNode(node)) { + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[currentContractName].structs[node.canonicalName] = { + contract: currentContractName, + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: natspec.dev || natspec.comment || getCommentsBefore(source.content, offset), + fields: extractStructFieldDocs(node.members, natspec, source), + }; + } else if (isEnumDefinitionNode(node)) { + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[currentContractName].enums[node.canonicalName] = { + contract: currentContractName, + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: natspec.dev || natspec.comment || getCommentsBefore(source.content, offset), + values: extractEnumValueDocs(node.members, natspec, source), + }; + } else if (isEventDefinitionNode(node)) { + const natspec = getNatspecBefore(source.content, offset); + docs.contracts[currentContractName].events.push({ + contract: currentContractName, + file: source.path, + line: getAstNodeLineNumber(node, source.content), + doc: natspec.dev || natspec.comment || getCommentsBefore(source.content, offset), + name: node.name, + parameters: extractFunctionParameterDocs(node.parameters, natspec, source), + }); + } + }; + visit(ast); + return docs; +} + +function extractAcessorParameterDocs(typeNameNode: TypeNameNode, natspec: Natspec, source: SourceData): ParamDocsMap { + const params: ParamDocsMap = {}; + const lineNumber = getAstNodeLineNumber(typeNameNode, source.content); + if (isMappingTypeNameNode(typeNameNode)) { + // Handle mappings. + let node = typeNameNode; + let order = 0; + do { + const paramName = `${Object.keys(params).length}`; + params[paramName] = { + file: source.path, + line: lineNumber, + doc: natspec.params[paramName] || '', + type: normalizeType(node.keyType.typeDescriptions.typeString), + indexed: false, + storageLocation: StorageLocation.Default, + order: order++, + }; + node = node.valueType as MappingTypeNameNode; + } while (isMappingTypeNameNode(node)); + } else if (isArrayTypeNameNode(typeNameNode)) { + // Handle arrays. + let node = typeNameNode; + let order = 0; + do { + const paramName = `${Object.keys(params).length}`; + params[paramName] = { + file: source.path, + line: lineNumber, + doc: natspec.params[paramName] || '', + type: 'uint256', + indexed: false, + storageLocation: StorageLocation.Default, + order: order++, + }; + node = node.baseType as ArrayTypeNameNode; + } while (isArrayTypeNameNode(node)); + } + return params; +} + +function extractAccesorReturnDocs(typeNameNode: TypeNameNode, natspec: Natspec, source: SourceData): ParamDocsMap { + let type = typeNameNode.typeDescriptions.typeString; + let storageLocation = StorageLocation.Default; + if (isMappingTypeNameNode(typeNameNode)) { + // Handle mappings. + let node = typeNameNode; + while (isMappingTypeNameNode(node.valueType)) { + node = node.valueType; + } + type = node.valueType.typeDescriptions.typeString; + storageLocation = type.startsWith('struct') ? StorageLocation.Memory : StorageLocation.Default; + } else if (isArrayTypeNameNode(typeNameNode)) { + // Handle arrays. + type = typeNameNode.baseType.typeDescriptions.typeString; + storageLocation = type.startsWith('struct') ? StorageLocation.Memory : StorageLocation.Default; + } else if (isUserDefinedTypeNameNode(typeNameNode)) { + storageLocation = typeNameNode.typeDescriptions.typeString.startsWith('struct') + ? StorageLocation.Memory + : StorageLocation.Default; + } + return { + '0': { + storageLocation, + type: normalizeType(type), + file: source.path, + line: getAstNodeLineNumber(typeNameNode, source.content), + doc: natspec.returns['0'] || '', + indexed: false, + order: 0, + }, + }; +} + +function extractFunctionParameterDocs( + paramListNodes: ParameterListNode, + natspec: Natspec, + source: SourceData, +): ParamDocsMap { + const params: ParamDocsMap = {}; + for (const param of paramListNodes.parameters) { + params[param.name] = { + file: source.path, + line: getAstNodeLineNumber(param, source.content), + doc: natspec.params[param.name] || '', + type: normalizeType(param.typeName.typeDescriptions.typeString), + indexed: param.indexed, + storageLocation: param.storageLocation, + order: 0, + }; + } + return params; +} + +function extractFunctionReturnDocs( + paramListNodes: ParameterListNode, + natspec: Natspec, + source: SourceData, +): ParamDocsMap { + const returns: ParamDocsMap = {}; + let order = 0; + for (const [idx, param] of Object.entries(paramListNodes.parameters)) { + returns[param.name || idx] = { + file: source.path, + line: getAstNodeLineNumber(param, source.content), + doc: natspec.returns[param.name || idx] || '', + type: normalizeType(param.typeName.typeDescriptions.typeString), + indexed: false, + storageLocation: param.storageLocation, + order: order++, + }; + } + return returns; +} + +function extractStructFieldDocs( + fieldNodes: VariableDeclarationNode[], + natspec: Natspec, + source: SourceData, +): ParamDocsMap { + const fields: ParamDocsMap = {}; + let order = 0; + for (const field of fieldNodes) { + const { offset } = splitAstNodeSrc(field.src); + fields[field.name] = { + file: source.path, + line: getAstNodeLineNumber(field, source.content), + doc: natspec.params[field.name] || getDocStringAround(source.content, offset), + type: normalizeType(field.typeName.typeDescriptions.typeString), + indexed: false, + storageLocation: field.storageLocation, + order: order++, + }; + } + return fields; +} + +function extractEnumValueDocs(valuesNodes: EnumValueNode[], natspec: Natspec, source: SourceData): EnumValueDocsMap { + const values: EnumValueDocsMap = {}; + for (const value of valuesNodes) { + const { offset } = splitAstNodeSrc(value.src); + values[value.name] = { + file: source.path, + line: getAstNodeLineNumber(value, source.content), + doc: natspec.params[value.name] || getDocStringAround(source.content, offset), + value: Object.keys(values).length, + }; + } + return values; +} + +function offsetToLineIndex(code: string, offset: number): number { + let currentOffset = 0; + let lineIdx = 0; + while (currentOffset <= offset) { + const lineEnd = code.indexOf('\n', currentOffset); + if (lineEnd === -1) { + return lineIdx; + } + currentOffset = lineEnd + 1; + ++lineIdx; + } + return lineIdx - 1; +} + +function offsetToLine(code: string, offset: number): string { + let lineEnd = code.substr(offset).search(/\r?\n/); + lineEnd = lineEnd === -1 ? code.length - offset : lineEnd; + let lineStart = code.lastIndexOf('\n', offset); + lineStart = lineStart === -1 ? 0 : lineStart; + return code.substr(lineStart, offset - lineStart + lineEnd).trim(); +} + +function getPrevLine(code: string, offset: number): [string | undefined, number] { + const lineStart = code.lastIndexOf('\n', offset); + if (lineStart <= 0) { + return [undefined, 0]; + } + const prevLineStart = code.lastIndexOf('\n', lineStart - 1); + if (prevLineStart === -1) { + return [code.substr(0, lineStart).trim(), 0]; + } + return [code.substring(prevLineStart + 1, lineStart).trim(), prevLineStart + 1]; +} + +function getAstNodeLineNumber(node: AstNode, code: string): number { + return offsetToLineIndex(code, splitAstNodeSrc(node.src).offset) + 1; +} + +function getNatspecBefore(code: string, offset: number): Natspec { + const natspec = { comment: '', dev: '', params: {}, returns: {} }; + // Walk backwards through the lines until there is no longer a natspec + // comment. + let currentDirectivePayloads = []; + let currentLine: string | undefined; + let currentOffset = offset; + while (true) { + [currentLine, currentOffset] = getPrevLine(code, currentOffset); + if (currentLine === undefined) { + break; + } + const m = /^\/\/\/\s*(?:@(\w+\b)\s*)?(.*?)$/.exec(currentLine); + if (!m) { + break; + } + const directive = m[1]; + let directiveParam: string | undefined; + let rest = m[2] || ''; + // Parse directives that take a parameter. + if (directive === 'param' || directive === 'return') { + const m2 = /^(\w+\b)(.*)$/.exec(rest); + if (m2) { + directiveParam = m2[1]; + rest = m2[2] || ''; + } + } + currentDirectivePayloads.push(rest); + if (directive !== undefined) { + const fullPayload = currentDirectivePayloads + .reverse() + .map(s => s.trim()) + .join(' '); + switch (directive) { + case 'dev': + natspec.dev = fullPayload; + break; + case 'param': + if (directiveParam) { + natspec.params = { + ...natspec.params, + [directiveParam]: fullPayload, + }; + } + break; + case 'return': + if (directiveParam) { + natspec.returns = { + ...natspec.returns, + [directiveParam]: fullPayload, + }; + } + break; + default: + break; + } + currentDirectivePayloads = []; + } + } + if (currentDirectivePayloads.length > 0) { + natspec.comment = currentDirectivePayloads + .reverse() + .map(s => s.trim()) + .join(' '); + } + return natspec; +} + +function getTrailingCommentAt(code: string, offset: number): string { + const m = /\/\/\s*(.+)\s*$/.exec(offsetToLine(code, offset)); + return m ? m[1] : ''; +} + +function getCommentsBefore(code: string, offset: number): string { + let currentOffset = offset; + const comments = []; + do { + let prevLine; + [prevLine, currentOffset] = getPrevLine(code, currentOffset); + if (prevLine === undefined) { + break; + } + const m = /^\s*\/\/\s*(.+)\s*$/.exec(prevLine); + if (m && !m[1].startsWith('solhint')) { + comments.push(m[1].trim()); + } else { + break; + } + } while (currentOffset > 0); + return comments.reverse().join(' '); +} + +function getDocStringBefore(code: string, offset: number): string { + const natspec = getNatspecBefore(code, offset); + return natspec.dev || natspec.comment || getCommentsBefore(code, offset); +} + +function getDocStringAround(code: string, offset: number): string { + const natspec = getNatspecBefore(code, offset); + return natspec.dev || natspec.comment || getDocStringBefore(code, offset) || getTrailingCommentAt(code, offset); +} + +function normalizeType(type: string): string { + const m = /^(?:\w+ )?(.*)$/.exec(type); + if (!m) { + return type; + } + return m[1]; +} + +// tslint:disable-next-line: max-file-line-count diff --git a/packages/sol-doc/src/gen_md.ts b/packages/sol-doc/src/gen_md.ts new file mode 100644 index 0000000000..e11f199247 --- /dev/null +++ b/packages/sol-doc/src/gen_md.ts @@ -0,0 +1,232 @@ +import { DocumentedItem, EventDocs, MethodDocs, SolidityDocs } from './extract_docs'; + +export interface MarkdownOpts { + urlPrefix: string; +} + +/** + * Convert JSON docs to markdown. + */ +export function generateMarkdownFromDocs(docs: SolidityDocs, opts: Partial = {}): string { + const lines: string[] = []; + const sortedContracts = Object.keys(docs.contracts).sort(); + for (const contractName of sortedContracts) { + lines.push(...generateContractsContent(contractName, docs, opts)); + } + return lines.join('\n'); +} + +function generateContractsContent(name: string, docs: SolidityDocs, opts: Partial): string[] { + const contract = docs.contracts[name]; + const enums = []; + const sortedEnums = Object.entries(contract.enums).sort(([a], [b]) => a.localeCompare(b)); + for (const [enumName, enumDocs] of sortedEnums) { + enums.push([ + `### ${toCode(enumName)}`, + enumDocs.doc, + '', + toSourceAttributionLink(enumDocs, opts), + '', + `***Members***`, + ...createTableContent( + ['Name', 'Value', 'Description'], + Object.entries(enumDocs.values).map(([n, d]) => [ + toSourceLink(toCode(n), d, opts), + toCode(d.value), + d.doc, + ]), + ), + ]); + } + const structSections = []; + const sortedStructs = Object.entries(contract.structs).sort(([a], [b]) => a.localeCompare(b)); + for (const [structName, structDocs] of sortedStructs) { + structSections.push([ + `### ${toCode(structName)}`, + structDocs.doc, + '', + toSourceAttributionLink(structDocs, opts), + '', + `***Fields***`, + ...createTableContent( + ['Name', 'Type', 'Description'], + Object.entries(structDocs.fields).map(([n, d]) => [ + toSourceLink(toCode(n), d, opts), + toCode(d.type), + d.doc, + ]), + ), + ]); + } + const eventSections = []; + const sortedEvents = contract.events.sort((a, b) => a.name.localeCompare(b.name)); + for (const event of sortedEvents) { + eventSections.push([ + `### ${toCode(event.name)}`, + event.doc, + '', + `• ${toCode(getEventSignature(event))}`, + '', + toSourceAttributionLink(event, opts), + '', + `***Parameters***`, + ...createTableContent( + ['Name', 'Type', 'Indexed', 'Description'], + Object.entries(event.parameters).map(([n, d]) => [ + toSourceLink(toCode(n), d, opts), + toCode(d.type), + toCode(d.indexed), + d.doc, + ]), + ), + ]); + } + const methodSections = []; + const sortedMethods = contract.methods.sort((a, b) => a.name.localeCompare(b.name)); + for (const method of sortedMethods) { + const annotation = method.isAccessor ? ' *(generated)*' : method.kind === 'fallback' ? ' *(fallback)*' : ''; + methodSections.push([ + `### ${toCode(getNormalizedMethodName(method))}`, + method.doc, + '', + `• ${toCode(getMethodSignature(method))}${annotation}`, + '', + toSourceAttributionLink(method, opts), + '', + ...(Object.keys(method.parameters).length !== 0 + ? [ + `***Parameters***`, + ...createTableContent( + ['Name', 'Type', 'Description'], + Object.entries(method.parameters).map(([n, d]) => [ + toSourceLink(toCode(n), d, opts), + toCode(d.type), + d.doc, + ]), + ), + ] + : []), + ...(Object.keys(method.returns).length !== 0 + ? [ + `***Returns***`, + ...createTableContent( + ['Name', 'Type', 'Description'], + Object.entries(method.returns).map(([n, d]) => [ + toSourceLink(toCode(n), d, opts), + toCode(d.type), + d.doc, + ]), + ), + ] + : []), + ]); + } + return [ + `# ${contract.kind} ${toCode(name)}`, + contract.doc, + '', + toSourceAttributionLink(contract, opts), + '', + ...(enums.length > 0 ? ['## Enums', ...joinSections(enums)] : []), + ...(structSections.length > 0 ? ['## Structs', ...joinSections(structSections)] : []), + ...(eventSections.length > 0 ? ['## Events', ...joinSections(eventSections)] : []), + ...(methodSections.length > 0 ? ['## Methods', ...joinSections(methodSections)] : []), + ]; +} + +interface Stringable { + toString(): string; +} + +function joinSections(sections: string[][]): string[] { + if (sections.length === 0) { + return []; + } + const joined: string[] = []; + for (const s of sections) { + joined.push(...s, '---'); + } + return joined.slice(0, joined.length - 1); +} + +function toCode(v: Stringable | boolean): string { + if (typeof v === 'boolean') { + return `\`${v ? true : false}\``; + } + return `\`${v}\``; +} + +function toSourceLink(text: string, doc: DocumentedItem, opts: Partial): string { + return `[${text}](${toSourceURL(doc.file, doc.line, opts.urlPrefix)})`; +} + +function toSourceAttributionLink(doc: DocumentedItem, opts: Partial): string { + return `  *Defined in ${toSourceLink(`${doc.file}:${doc.line}`, doc, opts)}*`; +} + +function toSourceURL(file: string, line: number, prefix?: string): string { + if (file.startsWith('/')) { + return `${file}#L${line}`; + } + const _prefix = !prefix || prefix.endsWith('/') ? prefix || '' : `${prefix}/`; + return `${_prefix}${file}#L${line}`; +} + +function getMethodSignature(method: MethodDocs): string { + const args = Object.entries(method.parameters).map(([_name, param]) => { + return /^\d+$/.test(_name) ? param.type : `${param.type} ${_name}`; + }); + const returns = Object.entries(method.returns).map(([_name, param]) => { + return /^\d+$/.test(_name) ? param.type : `${param.type} ${_name}`; + }); + const _returns = returns.length !== 0 ? `: (${returns.join(', ')})` : ''; + const mutabilityPrefix = ['view', 'pure'].includes(method.stateMutability) + ? 'constant ' + : method.stateMutability === 'payable' + ? 'payable ' + : ''; + return `${mutabilityPrefix}function ${getNormalizedMethodName(method)}(${args.join(', ')})${_returns}`; +} + +function getNormalizedMethodName(method: MethodDocs): string { + let name = method.name; + if (method.kind === 'constructor') { + name = 'constructor'; + } else if (method.kind === 'fallback') { + name = ''; + } + return name; +} + +function getEventSignature(event: EventDocs): string { + const args = Object.entries(event.parameters).map(([name, param]) => { + return /^\d+$/.test(name) ? param.type : `${param.type} ${name}`; + }); + return `event ${event.name}(${args.join(', ')})`; +} + +function createTableContent(headers: string[], rows: Stringable[][]): string[] { + const [_headers, _rows] = filterTableEmptyColumns(headers, rows); + const lines = [ + _headers.join(' | '), + _headers.map(h => h.replace(/./g, '-')).join(' | '), + ..._rows.map(r => r.join(' | ')), + ].map(line => `| ${line} |`); + return ['', ...lines, '']; +} + +function filterTableEmptyColumns(headers: string[], rows: Stringable[][]): [string[], Stringable[][]] { + const emptyColumnIndicesByRow = rows.map(r => r.map((c, i) => i).filter(i => r[i] === '')); + const emptyColumnIndices = emptyColumnIndicesByRow.reduce((acc, row) => { + for (const i of row) { + if (!acc.includes(i)) { + acc.push(i); + } + } + return acc; + }, []); + return [ + headers.filter((v, i) => !emptyColumnIndices.includes(i)), + rows.filter((v, i) => !emptyColumnIndices.includes(i)), + ]; +} diff --git a/packages/sol-doc/src/index.ts b/packages/sol-doc/src/index.ts index 521668cc87..21628c28b2 100644 --- a/packages/sol-doc/src/index.ts +++ b/packages/sol-doc/src/index.ts @@ -1 +1,20 @@ -export { SolDoc } from './sol_doc'; +export { + ContractDocs, + ContractKind, + EnumValueDocs, + EnumValueDocsMap, + EventDocs, + extractDocsAsync, + FunctionKind, + MethodDocs, + ParamDocs, + ParamDocsMap, + SolidityDocs, + StorageLocation, + StructDocs, + Visibility, +} from './extract_docs'; + +export { transformDocs, TransformOpts } from './transform_docs'; + +export { generateMarkdownFromDocs, MarkdownOpts } from './gen_md'; diff --git a/packages/sol-doc/src/sol_ast.ts b/packages/sol-doc/src/sol_ast.ts new file mode 100644 index 0000000000..e76872aa2c --- /dev/null +++ b/packages/sol-doc/src/sol_ast.ts @@ -0,0 +1,231 @@ +export enum AstNodeType { + SourceUnit = 'SourceUnit', + ContractDefinition = 'ContractDefinition', + FunctionDefinition = 'FunctionDefinition', + ParameterList = 'ParameterList', + VariableDeclaration = 'VariableDeclaration', + UserDefinedTypeName = 'UserDefinedTypeName', + ElementaryTypeName = 'ElementaryTypeName', + ArrayTypeName = 'ArrayTypeName', + Mapping = 'Mapping', + StructDefinition = 'StructDefinition', + EnumDefinition = 'EnumDefinition', + EnumValue = 'EnumValue', + InheritanceSpecifier = 'InheritanceSpecifier', + EventDefinition = 'EventDefinition', +} + +export enum Visibility { + Internal = 'internal', + External = 'external', + Public = 'public', + Private = 'private', +} + +export enum StateMutability { + Nonpayable = 'nonpayable', + Payable = 'payable', + View = 'view', + Pure = 'pure', +} + +export enum FunctionKind { + Constructor = 'constructor', + Function = 'function', + Fallback = 'fallback', +} + +export enum ContractKind { + Contract = 'contract', + Interface = 'interface', + Library = 'library', +} + +export enum StorageLocation { + Default = 'default', + Storage = 'storage', + Memory = 'memory', + CallData = 'calldata', +} + +export interface AstNode { + id: number; + nodeType: AstNodeType; + src: string; +} + +export interface SourceUnitNode extends AstNode { + path: string; + nodes: AstNode[]; + exportedSymbols: { + [symbol: string]: number[]; + }; +} + +export interface ContractDefinitionNode extends AstNode { + name: string; + contractKind: ContractKind; + fullyImplemented: boolean; + linearizedBaseContracts: number[]; + contractDependencies: number[]; + baseContracts: InheritanceSpecifierNode[]; + nodes: AstNode[]; +} + +export interface InheritanceSpecifierNode extends AstNode { + baseName: UserDefinedTypeNameNode; +} + +export interface FunctionDefinitionNode extends AstNode { + name: string; + implemented: boolean; + scope: number; + kind: FunctionKind; + parameters: ParameterListNode; + returnParameters: ParameterListNode; + visibility: Visibility; + stateMutability: StateMutability; +} + +export interface ParameterListNode extends AstNode { + parameters: VariableDeclarationNode[]; +} + +export interface VariableDeclarationNode extends AstNode { + name: string; + value: AstNode | null; + constant: boolean; + scope: number; + visibility: Visibility; + stateVariable: boolean; + storageLocation: StorageLocation; + indexed: boolean; + typeName: TypeNameNode; +} + +export interface TypeNameNode extends AstNode { + name: string; + typeDescriptions: { + typeIdentifier: string; + typeString: string; + }; +} + +export interface UserDefinedTypeNameNode extends TypeNameNode { + referencedDeclaration: number; +} + +export interface MappingTypeNameNode extends TypeNameNode { + keyType: ElementaryTypeNameNode; + valueType: TypeNameNode; +} + +export interface ElementaryTypeNameNode extends TypeNameNode {} + +export interface ArrayTypeNameNode extends TypeNameNode { + length: number | null; + baseType: TypeNameNode; +} + +export interface StructDefinitionNode extends AstNode { + scope: number; + name: string; + canonicalName: string; + members: VariableDeclarationNode[]; +} + +export interface EnumDefinitionNode extends AstNode { + name: string; + canonicalName: string; + members: EnumValueNode[]; +} + +export interface EnumValueNode extends AstNode { + name: string; +} + +export interface EventDefinitionNode extends AstNode { + name: string; + parameters: ParameterListNode; +} + +/** + * Check if a node is a SourceUnit node. + */ +export function isSourceUnitNode(node: AstNode): node is SourceUnitNode { + return node.nodeType === AstNodeType.SourceUnit; +} + +/** + * Check if a node is a ContractDefinition ode. + */ +export function isContractDefinitionNode(node: AstNode): node is ContractDefinitionNode { + return node.nodeType === AstNodeType.ContractDefinition; +} + +/** + * Check if a node is a VariableDeclaration ode. + */ +export function isVariableDeclarationNode(node: AstNode): node is VariableDeclarationNode { + return node.nodeType === AstNodeType.VariableDeclaration; +} + +/** + * Check if a node is a FunctionDefinition node. + */ +export function isFunctionDefinitionNode(node: AstNode): node is FunctionDefinitionNode { + return node.nodeType === AstNodeType.FunctionDefinition; +} + +/** + * Check if a node is a StructDefinition ode. + */ +export function isStructDefinitionNode(node: AstNode): node is StructDefinitionNode { + return node.nodeType === AstNodeType.StructDefinition; +} + +/** + * Check if a node is a EnumDefinition ode. + */ +export function isEnumDefinitionNode(node: AstNode): node is EnumDefinitionNode { + return node.nodeType === AstNodeType.EnumDefinition; +} + +/** + * Check if a node is a Mapping node. + */ +export function isMappingTypeNameNode(node: AstNode): node is MappingTypeNameNode { + return node.nodeType === AstNodeType.Mapping; +} + +/** + * Check if a node is a ArrayTypeName node. + */ +export function isArrayTypeNameNode(node: AstNode): node is ArrayTypeNameNode { + return node.nodeType === AstNodeType.ArrayTypeName; +} + +/** + * Check if a node is a UserDefinedTypeName node. + */ +export function isUserDefinedTypeNameNode(node: AstNode): node is UserDefinedTypeNameNode { + return node.nodeType === AstNodeType.UserDefinedTypeName; +} + +/** + * Check if a node is a EventDefinition node. + */ +export function isEventDefinitionNode(node: AstNode): node is EventDefinitionNode { + return node.nodeType === AstNodeType.EventDefinition; +} + +/** + * Split an AST source mapping string into its parts. + */ +export function splitAstNodeSrc(src: string): { offset: number; length: number; sourceId: number } { + // tslint:disable-next-line: custom-no-magic-numbers + const [offset, length, sourceId] = src.split(':').map(s => parseInt(s, 10)); + return { offset, length, sourceId }; +} + +// tslint:disable-next-line: max-file-line-count diff --git a/packages/sol-doc/src/sol_doc.ts b/packages/sol-doc/src/sol_doc.ts deleted file mode 100644 index 5992bd6931..0000000000 --- a/packages/sol-doc/src/sol_doc.ts +++ /dev/null @@ -1,505 +0,0 @@ -import * as path from 'path'; - -import { - AbiDefinition, - ConstructorAbi, - DataItem, - DevdocOutput, - EventAbi, - EventParameter, - FallbackAbi, - MethodAbi, - StandardContractOutput, -} from 'ethereum-types'; -import ethUtil = require('ethereumjs-util'); -import * as _ from 'lodash'; - -import { Compiler, CompilerOptions } from '@0x/sol-compiler'; -import { - CustomType, - CustomTypeChild, - DocAgnosticFormat, - DocSection, - Event, - EventArg, - ObjectMap, - Parameter, - SolidityMethod, - Type, - TypeDocTypes, -} from '@0x/types'; - -export class SolDoc { - private _customTypeHashToName: ObjectMap | undefined; - private static _genEventDoc(abiDefinition: EventAbi): Event { - const eventDoc: Event = { - name: abiDefinition.name, - eventArgs: SolDoc._genEventArgsDoc(abiDefinition.inputs), - }; - return eventDoc; - } - private static _devdocMethodDetailsIfExist( - methodSignature: string, - devdocIfExists: DevdocOutput | undefined, - ): string | undefined { - let details; - if ( - devdocIfExists !== undefined && - devdocIfExists.methods !== undefined && - devdocIfExists.methods[methodSignature] !== undefined && - devdocIfExists.methods[methodSignature].details !== undefined - ) { - details = devdocIfExists.methods[methodSignature].details; - } - - return details; - } - private static _genFallbackDoc( - abiDefinition: FallbackAbi, - devdocIfExists: DevdocOutput | undefined, - ): SolidityMethod { - const methodSignature = `()`; - const comment = SolDoc._devdocMethodDetailsIfExist(methodSignature, devdocIfExists); - - const returnComment = - devdocIfExists === undefined || devdocIfExists.methods[methodSignature] === undefined - ? undefined - : devdocIfExists.methods[methodSignature].return; - - const methodDoc: SolidityMethod = { - isConstructor: false, - name: 'fallback', - callPath: '', - parameters: [], - returnType: { name: 'void', typeDocType: TypeDocTypes.Intrinsic }, - returnComment, - isConstant: true, - isPayable: abiDefinition.payable, - isFallback: true, - comment: _.isEmpty(comment) - ? 'The fallback function. It is executed on a call to the contract if none of the other functions match the given public identifier (or if no data was supplied at all).' - : comment, - }; - return methodDoc; - } - private static _genEventArgsDoc(args: EventParameter[]): EventArg[] { - const eventArgsDoc: EventArg[] = []; - - for (const arg of args) { - const name = arg.name; - - const type: Type = { - name: arg.type, - typeDocType: TypeDocTypes.Intrinsic, - }; - - const eventArgDoc: EventArg = { - isIndexed: arg.indexed, - name, - type, - }; - - eventArgsDoc.push(eventArgDoc); - } - return eventArgsDoc; - } - private static _dedupStructs(customTypes: CustomType[]): CustomType[] { - const uniqueCustomTypes: CustomType[] = []; - const seenTypes: { [hash: string]: boolean } = {}; - _.each(customTypes, customType => { - const hash = SolDoc._generateCustomTypeHash(customType); - if (!seenTypes[hash]) { - uniqueCustomTypes.push(customType); - seenTypes[hash] = true; - } - }); - return uniqueCustomTypes; - } - private static _capitalize(text: string): string { - return `${text.charAt(0).toUpperCase()}${text.slice(1)}`; - } - private static _generateCustomTypeHash(customType: CustomType): string { - const customTypeWithoutName = _.cloneDeep(customType); - delete customTypeWithoutName.name; - const customTypeWithoutNameStr = JSON.stringify(customTypeWithoutName); - const hash = ethUtil.sha256(customTypeWithoutNameStr).toString('hex'); - return hash; - } - private static _makeCompilerOptions(contractsDir: string, contractsToCompile?: string[]): CompilerOptions { - const compilerOptions: CompilerOptions = { - contractsDir, - contracts: '*', - compilerSettings: { - outputSelection: { - ['*']: { - ['*']: ['abi', 'devdoc'], - }, - }, - }, - }; - - const shouldOverrideCatchAllContractsConfig = contractsToCompile !== undefined && contractsToCompile.length > 0; - if (shouldOverrideCatchAllContractsConfig) { - compilerOptions.contracts = contractsToCompile; - } - - return compilerOptions; - } - /** - * Invoke the Solidity compiler and transform its ABI and devdoc outputs into a - * JSON format easily consumed by documentation rendering tools. - * @param contractsToDocument list of contracts for which to generate doc objects - * @param contractsDir the directory in which to find the `contractsToCompile` as well as their dependencies. - * @return doc object for use with documentation generation tools. - */ - public async generateSolDocAsync( - contractsDir: string, - contractsToDocument?: string[], - customTypeHashToName?: ObjectMap, - ): Promise { - this._customTypeHashToName = customTypeHashToName; - const docWithDependencies: DocAgnosticFormat = {}; - const compilerOptions = SolDoc._makeCompilerOptions(contractsDir, contractsToDocument); - const compiler = new Compiler(compilerOptions); - const compilerOutputs = await compiler.getCompilerOutputsAsync(); - let structs: CustomType[] = []; - for (const compilerOutput of compilerOutputs) { - const contractFileNames = _.keys(compilerOutput.contracts); - for (const contractFileName of contractFileNames) { - const contractNameToOutput = compilerOutput.contracts[contractFileName]; - - const contractNames = _.keys(contractNameToOutput); - for (const contractName of contractNames) { - const compiledContract = contractNameToOutput[contractName]; - if (compiledContract.abi === undefined) { - throw new Error('compiled contract did not contain ABI output'); - } - docWithDependencies[contractName] = this._genDocSection(compiledContract, contractName); - structs = [...structs, ...this._extractStructs(compiledContract)]; - } - } - } - structs = SolDoc._dedupStructs(structs); - structs = this._overwriteStructNames(structs); - - let doc: DocAgnosticFormat = {}; - if (contractsToDocument === undefined || contractsToDocument.length === 0) { - doc = docWithDependencies; - } else { - for (const contractToDocument of contractsToDocument) { - const contractBasename = path.basename(contractToDocument); - const contractName = - contractBasename.lastIndexOf('.sol') === -1 - ? contractBasename - : contractBasename.substring(0, contractBasename.lastIndexOf('.sol')); - doc[contractName] = docWithDependencies[contractName]; - } - } - - if (structs.length > 0) { - doc.structs = { - comment: '', - constructors: [], - methods: [], - properties: [], - types: structs, - functions: [], - events: [], - }; - } - - delete this._customTypeHashToName; // Clean up instance state - return doc; - } - private _getCustomTypeFromDataItem(inputOrOutput: DataItem): CustomType { - const customType: CustomType = { - name: _.capitalize(inputOrOutput.name), - kindString: 'Interface', - children: [], - }; - _.each(inputOrOutput.components, (component: DataItem) => { - const childType = this._getTypeFromDataItem(component); - const customTypeChild = { - name: component.name, - type: childType, - }; - // (fabio): Not sure why this type casting is necessary. Seems TS doesn't - // deduce that `customType.children` cannot be undefined anymore after being - // set to `[]` above. - (customType.children as CustomTypeChild[]).push(customTypeChild); - }); - return customType; - } - private _getNameFromDataItemIfExists(dataItem: DataItem): string | undefined { - if (dataItem.components === undefined) { - return undefined; - } - const customType = this._getCustomTypeFromDataItem(dataItem); - const hash = SolDoc._generateCustomTypeHash(customType); - if (this._customTypeHashToName === undefined || this._customTypeHashToName[hash] === undefined) { - return undefined; - } - return this._customTypeHashToName[hash]; - } - private _getTypeFromDataItem(dataItem: DataItem): Type { - const typeDocType = dataItem.components !== undefined ? TypeDocTypes.Reference : TypeDocTypes.Intrinsic; - let typeName: string; - if (typeDocType === TypeDocTypes.Reference) { - const nameIfExists = this._getNameFromDataItemIfExists(dataItem); - typeName = nameIfExists === undefined ? SolDoc._capitalize(dataItem.name) : nameIfExists; - } else { - typeName = dataItem.type; - } - - const isArrayType = _.endsWith(dataItem.type, '[]'); - let type: Type; - if (isArrayType) { - // tslint:disable-next-line:custom-no-magic-numbers - typeName = typeDocType === TypeDocTypes.Intrinsic ? typeName.slice(0, -2) : typeName; - type = { - elementType: { name: typeName, typeDocType }, - typeDocType: TypeDocTypes.Array, - name: '', - }; - } else { - type = { name: typeName, typeDocType }; - } - return type; - } - private _overwriteStructNames(customTypes: CustomType[]): CustomType[] { - if (this._customTypeHashToName === undefined) { - return customTypes; - } - const localCustomTypes = _.cloneDeep(customTypes); - _.each(localCustomTypes, (customType, i) => { - const hash = SolDoc._generateCustomTypeHash(customType); - if (this._customTypeHashToName !== undefined && this._customTypeHashToName[hash] !== undefined) { - localCustomTypes[i].name = this._customTypeHashToName[hash]; - } - }); - return localCustomTypes; - } - private _extractStructs(compiledContract: StandardContractOutput): CustomType[] { - let customTypes: CustomType[] = []; - for (const abiDefinition of compiledContract.abi) { - let types: CustomType[] = []; - switch (abiDefinition.type) { - case 'constructor': { - types = this._getStructsAsCustomTypes(abiDefinition); - break; - } - case 'function': { - types = this._getStructsAsCustomTypes(abiDefinition); - break; - } - case 'event': - case 'fallback': - // No types exist - break; - default: - throw new Error( - `unknown and unsupported AbiDefinition type '${(abiDefinition as AbiDefinition).type}'`, // tslint:disable-line:no-unnecessary-type-assertion - ); - } - customTypes = [...customTypes, ...types]; - } - return customTypes; - } - private _genDocSection(compiledContract: StandardContractOutput, contractName: string): DocSection { - const docSection: DocSection = { - comment: - compiledContract.devdoc === undefined || compiledContract.devdoc.title === undefined - ? '' - : compiledContract.devdoc.title, - constructors: [], - methods: [], - properties: [], - types: [], - functions: [], - events: [], - }; - - for (const abiDefinition of compiledContract.abi) { - switch (abiDefinition.type) { - case 'constructor': - docSection.constructors.push( - // tslint:disable-next-line:no-unnecessary-type-assertion - this._genConstructorDoc(contractName, abiDefinition as ConstructorAbi, compiledContract.devdoc), - ); - break; - case 'event': - // tslint:disable-next-line:no-unnecessary-type-assertion - (docSection.events as Event[]).push(SolDoc._genEventDoc(abiDefinition as EventAbi)); - // note that we're not sending devdoc to this._genEventDoc(). - // that's because the type of the events array doesn't have any fields for documentation! - break; - case 'function': - // tslint:disable-next-line:no-unnecessary-type-assertion - docSection.methods.push(this._genMethodDoc(abiDefinition as MethodAbi, compiledContract.devdoc)); - break; - case 'fallback': - // tslint:disable-next-line:no-unnecessary-type-assertion - docSection.methods.push( - SolDoc._genFallbackDoc(abiDefinition as FallbackAbi, compiledContract.devdoc), - ); - break; - default: - throw new Error( - `unknown and unsupported AbiDefinition type '${(abiDefinition as AbiDefinition).type}'`, // tslint:disable-line:no-unnecessary-type-assertion - ); - } - } - - return docSection; - } - private _genConstructorDoc( - contractName: string, - abiDefinition: ConstructorAbi, - devdocIfExists: DevdocOutput | undefined, - ): SolidityMethod { - const { parameters, methodSignature } = this._genMethodParamsDoc('', abiDefinition.inputs, devdocIfExists); - - const comment = SolDoc._devdocMethodDetailsIfExist(methodSignature, devdocIfExists); - - const constructorDoc: SolidityMethod = { - isConstructor: true, - name: contractName, - callPath: '', - parameters, - returnType: { name: contractName, typeDocType: TypeDocTypes.Reference }, // sad we have to specify this - isConstant: false, - isPayable: abiDefinition.payable, - comment, - }; - - return constructorDoc; - } - private _genMethodDoc(abiDefinition: MethodAbi, devdocIfExists: DevdocOutput | undefined): SolidityMethod { - const name = abiDefinition.name; - const { parameters, methodSignature } = this._genMethodParamsDoc(name, abiDefinition.inputs, devdocIfExists); - const devDocComment = SolDoc._devdocMethodDetailsIfExist(methodSignature, devdocIfExists); - const returnType = this._genMethodReturnTypeDoc(abiDefinition.outputs); - const returnComment = - devdocIfExists === undefined || devdocIfExists.methods[methodSignature] === undefined - ? undefined - : devdocIfExists.methods[methodSignature].return; - - const hasNoNamedParameters = _.find(parameters, p => !_.isEmpty(p.name)) === undefined; - const isGeneratedGetter = hasNoNamedParameters; - const comment = - _.isEmpty(devDocComment) && isGeneratedGetter - ? `This is an auto-generated accessor method of the '${name}' contract instance variable.` - : devDocComment; - const methodDoc: SolidityMethod = { - isConstructor: false, - name, - callPath: '', - parameters, - returnType, - returnComment, - isConstant: abiDefinition.constant, - isPayable: abiDefinition.payable, - comment, - }; - return methodDoc; - } - /** - * Extract documentation for each method parameter from @param params. - */ - private _genMethodParamsDoc( - name: string, - abiParams: DataItem[], - devdocIfExists: DevdocOutput | undefined, - ): { parameters: Parameter[]; methodSignature: string } { - const parameters: Parameter[] = []; - for (const abiParam of abiParams) { - const type = this._getTypeFromDataItem(abiParam); - - const parameter: Parameter = { - name: abiParam.name, - comment: '', - isOptional: false, // Unsupported in Solidity, until resolution of https://github.com/ethereum/solidity/issues/232 - type, - }; - parameters.push(parameter); - } - - const methodSignature = `${name}(${abiParams - .map(abiParam => { - if (!_.startsWith(abiParam.type, 'tuple')) { - return abiParam.type; - } else { - // Need to expand tuples: - // E.g: fillOrder(tuple,uint256,bytes) -> fillOrder((address,address,address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes,bytes),uint256,bytes) - const isArray = _.endsWith(abiParam.type, '[]'); - const expandedTypes = _.map(abiParam.components, c => c.type); - const type = `(${expandedTypes.join(',')})${isArray ? '[]' : ''}`; - return type; - } - }) - .join(',')})`; - - if (devdocIfExists !== undefined) { - const devdocMethodIfExists = devdocIfExists.methods[methodSignature]; - if (devdocMethodIfExists !== undefined) { - const devdocParamsIfExist = devdocMethodIfExists.params; - if (devdocParamsIfExist !== undefined) { - for (const parameter of parameters) { - parameter.comment = devdocParamsIfExist[parameter.name]; - } - } - } - } - - return { parameters, methodSignature }; - } - private _genMethodReturnTypeDoc(outputs: DataItem[]): Type { - let type: Type; - if (outputs.length > 1) { - type = { - name: '', - typeDocType: TypeDocTypes.Tuple, - tupleElements: [], - }; - for (const output of outputs) { - const tupleType = this._getTypeFromDataItem(output); - (type.tupleElements as Type[]).push(tupleType); - } - return type; - } else if (outputs.length === 1) { - const output = outputs[0]; - type = this._getTypeFromDataItem(output); - } else { - type = { - name: 'void', - typeDocType: TypeDocTypes.Intrinsic, - }; - } - return type; - } - private _getStructsAsCustomTypes(abiDefinition: AbiDefinition): CustomType[] { - const customTypes: CustomType[] = []; - // We cast to `any` here because we do not know yet if this type of abiDefinition contains - // an `input` key - if ((abiDefinition as any).inputs !== undefined) { - const methodOrConstructorAbi = abiDefinition as MethodAbi | ConstructorAbi; - _.each(methodOrConstructorAbi.inputs, input => { - if (!input.components === undefined) { - const customType = this._getCustomTypeFromDataItem(input); - customTypes.push(customType); - } - }); - } - if ((abiDefinition as any).outputs !== undefined) { - const methodAbi = abiDefinition as MethodAbi; // tslint:disable-line:no-unnecessary-type-assertion - _.each(methodAbi.outputs, output => { - if (output.components !== undefined) { - const customType = this._getCustomTypeFromDataItem(output); - customTypes.push(customType); - } - }); - } - return customTypes; - } -} -// tslint:disable:max-file-line-count diff --git a/packages/sol-doc/src/transform_docs.ts b/packages/sol-doc/src/transform_docs.ts new file mode 100644 index 0000000000..dd7e942ae8 --- /dev/null +++ b/packages/sol-doc/src/transform_docs.ts @@ -0,0 +1,235 @@ +import { ContractDocs, EventDocs, MethodDocs, ParamDocsMap, SolidityDocs, Visibility } from './extract_docs'; + +export interface TransformOpts { + onlyExposed: boolean; + flatten: boolean; + contracts: string[]; +} + +interface TypesUsage { + [type: string]: { + methods: MethodDocs[]; + events: EventDocs[]; + structs: string[]; + }; +} + +/** + * Apply some nice transformations to extracted JSON docs, such as flattening + * inherited contracts and filtering out unexposed or unused types. + */ +export function transformDocs(docs: SolidityDocs, opts: Partial = {}): SolidityDocs { + const _opts = { + onlyExposed: false, + flatten: false, + contracts: undefined, + ...opts, + }; + const _docs = { + ...docs, + contracts: { ...docs.contracts }, + }; + if (_opts.flatten) { + for (const [contractName] of Object.entries(docs.contracts)) { + _docs.contracts[contractName] = flattenContract(contractName, docs); + } + } + return filterTypes(_docs, _opts.contracts || Object.keys(docs.contracts), _opts.onlyExposed); +} + +function flattenContract(contractName: string, docs: SolidityDocs, seen: string[] = []): ContractDocs { + seen.push(contractName); + const contract = docs.contracts[contractName]; + const bases = []; + for (const ancestor of contract.inherits) { + if (!seen.includes(ancestor)) { + bases.push(flattenContract(ancestor, docs, seen)); + } + } + return mergeContracts([...bases, contract]); +} + +function mergeContracts(contracts: ContractDocs[]): ContractDocs { + return { + ...contracts[contracts.length - 1], + methods: mergeMethods(concat(...contracts.map(c => c.methods))), + events: mergeEvents(concat(...contracts.map(c => c.events))), + }; +} + +function concat(...arrs: T[][]): T[] { + return arrs.reduce((prev: T[], curr: T[]) => { + prev.push(...curr); + return prev; + }, []); +} + +function mergeMethods(methods: MethodDocs[]): MethodDocs[] { + const ids: string[] = []; + const merged: MethodDocs[] = []; + for (const method of methods) { + if (method.visibility === Visibility.Private) { + continue; + } + const id = getMethodId(method.name, method.parameters); + if (method.kind === 'constructor') { + const constructorIndex = merged.findIndex(m => m.kind === 'constructor'); + if (constructorIndex !== -1) { + merged[constructorIndex] = method; + ids[constructorIndex] = id; + continue; + } + } + const existingIdx = ids.indexOf(id); + if (existingIdx !== -1) { + merged[existingIdx] = method; + ids[existingIdx] = id; + } else { + merged.push(method); + ids.push(id); + } + } + return merged; +} + +function mergeEvents(events: EventDocs[]): EventDocs[] { + const ids: string[] = []; + const merged: EventDocs[] = []; + for (const event of events) { + const selector = getMethodId(event.name, event.parameters); + const existingIdx = ids.indexOf(selector); + if (existingIdx !== -1) { + merged[existingIdx] = event; + ids[existingIdx] = selector; + } else { + merged.push(event); + ids.push(selector); + } + } + return merged; +} + +function getMethodId(name: string, params: ParamDocsMap): string { + const paramsTypes = Object.values(params).map(p => p.type); + return `${name}(${paramsTypes.join(',')})`; +} + +function filterTypes(docs: SolidityDocs, contracts: string[], onlyExposed: boolean = false): SolidityDocs { + const inheritedContracts = getAllInheritedContracts(contracts, docs); + const contractsWithInheritance = [...inheritedContracts, ...contracts]; + const filteredDocs: SolidityDocs = { + ...docs, + contracts: {}, + }; + const usages = getTypeUsage(docs); + for (const [contractName, contract] of Object.entries(docs.contracts)) { + if (inheritedContracts.includes(contractName) && !contracts.includes(contractName)) { + continue; + } + const filteredContract: ContractDocs = { + ...contract, + methods: contract.methods.filter(m => !onlyExposed || isMethodVisible(m)), + structs: {}, + enums: {}, + }; + for (const [typeName, doc] of Object.entries(contract.structs)) { + if (isTypeUsedByContracts(typeName, usages, contractsWithInheritance, onlyExposed)) { + filteredContract.structs[typeName] = doc; + } + } + for (const [typeName, doc] of Object.entries(contract.enums)) { + if (isTypeUsedByContracts(typeName, usages, contractsWithInheritance, onlyExposed)) { + filteredContract.enums[typeName] = doc; + } + } + if ( + contracts.includes(contractName) || + Object.keys(filteredContract.structs).length !== 0 || + Object.keys(filteredContract.enums).length !== 0 + ) { + filteredDocs.contracts[contractName] = filteredContract; + } + } + return filteredDocs; +} + +function getAllInheritedContracts(contracts: string[], docs: SolidityDocs): string[] { + const result: string[] = []; + for (const contract of contracts) { + for (const inherited of docs.contracts[contract].inherits) { + if (result.includes(inherited)) { + continue; + } + result.push(inherited, ...getAllInheritedContracts([inherited], docs)); + } + } + return result; +} + +function getTypeUsage(docs: SolidityDocs): TypesUsage { + const types: TypesUsage = {}; + const addTypeUser = (type: string, user: { method?: MethodDocs; event?: EventDocs; struct?: string }) => { + if (types[type] === undefined) { + types[type] = { methods: [], events: [], structs: [] }; + } + if (user.method !== undefined) { + types[type].methods.push(user.method); + } + if (user.event !== undefined) { + types[type].events.push(user.event); + } + if (user.struct !== undefined) { + types[type].structs.push(user.struct); + } + }; + for (const contract of Object.values(docs.contracts)) { + for (const [typeName, doc] of Object.entries(contract.structs)) { + for (const field of Object.values(doc.fields)) { + addTypeUser(field.type, { struct: typeName }); + } + } + for (const doc of contract.events) { + for (const param of Object.values(doc.parameters)) { + addTypeUser(param.type, { event: doc }); + } + } + for (const doc of contract.methods) { + for (const param of Object.values(doc.parameters)) { + addTypeUser(param.type, { method: doc }); + } + for (const param of Object.values(doc.returns)) { + addTypeUser(param.type, { method: doc }); + } + } + } + return types; +} + +function isTypeUsedByContracts( + type: string, + usages: TypesUsage, + contracts: string[], + onlyExposed: boolean = false, +): boolean { + const usage = usages[type]; + if (usage === undefined) { + return false; + } + for (const struct of usage.structs) { + if (isTypeUsedByContracts(struct, usages, contracts, onlyExposed)) { + return true; + } + } + if (usage.events.some(e => contracts.includes(e.contract))) { + return true; + } + if (usage.methods.filter(m => !onlyExposed || isMethodVisible(m)).some(m => contracts.includes(m.contract))) { + return true; + } + return false; +} + +function isMethodVisible(method: MethodDocs): boolean { + const VISIBLES = [Visibility.External, Visibility.Public]; + return VISIBLES.includes(method.visibility); +} diff --git a/packages/sol-doc/test/extract_docs_test.ts b/packages/sol-doc/test/extract_docs_test.ts new file mode 100644 index 0000000000..66d520aa68 --- /dev/null +++ b/packages/sol-doc/test/extract_docs_test.ts @@ -0,0 +1,514 @@ +import { chaiSetup } from '@0x/dev-utils'; +import { expect } from 'chai'; +import * as _ from 'lodash'; +import * as path from 'path'; + +import { extractDocsAsync, MethodDocs, SolidityDocs, StorageLocation, Visibility } from '../src/extract_docs'; + +chaiSetup.configure(); + +// tslint:disable: custom-no-magic-numbers +describe('extractDocsAsync()', () => { + const INTERFACE_CONTRACT = 'InterfaceContract'; + const TEST_CONTRACT = 'TestContract'; + const BASE_CONTRACT = 'BaseContract'; + const LIBRARY_CONTRACT = 'LibraryContract'; + const INPUT_CONTRACTS = [TEST_CONTRACT, BASE_CONTRACT, LIBRARY_CONTRACT, INTERFACE_CONTRACT]; + const INPUT_FILE_PATHS = INPUT_CONTRACTS.map(f => path.resolve(__dirname, '../../test/inputs', `${f}.sol`)); + + let docs: SolidityDocs; + + function createDocString(itemName: string): string { + return `Documentation for \`${itemName}\`.`; + } + + before(async () => { + docs = await extractDocsAsync(_.shuffle(INPUT_FILE_PATHS)); + }); + + describe('contracts', () => { + it('extracts all contracts with docs', async () => { + const contractLines: { [name: string]: number } = { + [TEST_CONTRACT]: 10, + [BASE_CONTRACT]: 9, + [INTERFACE_CONTRACT]: 4, + [LIBRARY_CONTRACT]: 5, + }; + const NO_DOCS = [INTERFACE_CONTRACT]; + for (const contract of INPUT_CONTRACTS) { + const cd = docs.contracts[contract]; + expect(cd).to.exist(''); + if (NO_DOCS.includes(contract)) { + expect(cd.doc).to.eq(''); + } else { + expect(cd.doc).to.eq(createDocString(contract)); + } + expect(cd.line, `${contract}.line`).to.eq(contractLines[contract]); + } + }); + + it('extracts contract inheritance', async () => { + const contractInherits: { [name: string]: string[] } = { + [TEST_CONTRACT]: [BASE_CONTRACT, INTERFACE_CONTRACT], + [BASE_CONTRACT]: [], + [INTERFACE_CONTRACT]: [], + [LIBRARY_CONTRACT]: [], + }; + for (const contract of INPUT_CONTRACTS) { + const cd = docs.contracts[contract]; + expect(cd.inherits).to.deep.eq(contractInherits[contract]); + } + }); + }); + + describe('methods', () => { + interface ExpectedMethodProps { + noDoc?: boolean; + line: number; + visibility: Visibility; + params?: { + [name: string]: { + noDoc?: boolean; + line: number; + type: string; + storage?: StorageLocation; + }; + }; + returns?: { + [name: string]: { + noDoc?: boolean; + line: number; + type: string; + storage?: StorageLocation; + }; + }; + } + + function assertMethodDocs(fullMethodName: string, props: ExpectedMethodProps): void { + const [contractName, methodName] = fullMethodName.split('.'); + const m = docs.contracts[contractName].methods.find(_m => _m.name === methodName) as MethodDocs; + { + const doc = props.noDoc ? '' : createDocString(methodName); + expect(m).to.exist(''); + expect(m.visibility).to.eq(props.visibility); + expect(m.contract).to.eq(contractName); + expect(m.doc).to.eq(doc); + } + const params = props.params || {}; + expect(Object.keys(m.parameters), 'number of parameters').to.be.length(Object.keys(params).length); + for (const [paramName, paramDoc] of Object.entries(params)) { + const actualParam = m.parameters[paramName]; + const doc = paramDoc.noDoc ? '' : createDocString(paramName); + const storage = paramDoc.storage === undefined ? StorageLocation.Default : paramDoc.storage; + expect(actualParam).to.exist(''); + expect(actualParam.doc).to.eq(doc); + expect(actualParam.line).to.eq(paramDoc.line); + expect(actualParam.storageLocation).to.eq(storage); + expect(actualParam.type).to.eq(paramDoc.type); + } + const returns = props.returns || {}; + expect(Object.keys(m.returns), 'number of returns').to.be.length(Object.keys(returns).length); + for (const [returnName, returnDoc] of Object.entries(returns)) { + const actualReturn = m.returns[returnName]; + const doc = returnDoc.noDoc ? '' : createDocString(returnName); + const storage = returnDoc.storage === undefined ? StorageLocation.Default : returnDoc.storage; + expect(actualReturn).to.exist(''); + expect(actualReturn.doc).to.eq(doc); + expect(actualReturn.line).to.eq(returnDoc.line); + expect(actualReturn.storageLocation).to.eq(storage); + expect(actualReturn.type).to.eq(returnDoc.type); + } + } + + describe('`TestContract`', () => { + it('`testContractMethod1`', () => { + assertMethodDocs('TestContract.testContractMethod1', { + line: 15, + visibility: Visibility.Public, + }); + }); + + it('`testContractMethod2`', () => { + assertMethodDocs('TestContract.testContractMethod2', { + line: 15, + visibility: Visibility.Internal, + params: { + p1: { + line: 24, + type: 'address', + }, + p2: { + line: 25, + type: 'uint256', + }, + p3: { + line: 26, + type: 'LibraryContract.LibraryContractEnum', + }, + }, + returns: { + r1: { + line: 29, + type: 'int32', + }, + }, + }); + }); + + it('`testContractMethod3`', () => { + assertMethodDocs('TestContract.testContractMethod3', { + line: 37, + visibility: Visibility.External, + params: { + p1: { + line: 37, + type: 'InterfaceContract.InterfaceStruct', + storage: StorageLocation.CallData, + }, + }, + returns: { + r1: { + line: 39, + type: 'bytes32[][]', + storage: StorageLocation.Memory, + }, + }, + }); + }); + + it('`testContractMethod4`', () => { + assertMethodDocs('TestContract.testContractMethod4', { + line: 45, + visibility: Visibility.Private, + params: { + p1: { + line: 46, + type: 'LibraryContract.LibraryStruct[]', + noDoc: true, + storage: StorageLocation.Storage, + }, + p2: { + line: 47, + type: 'InterfaceContract.InterfaceStruct[]', + noDoc: true, + storage: StorageLocation.Memory, + }, + p3: { + line: 48, + type: 'bytes[]', + noDoc: true, + storage: StorageLocation.Memory, + }, + }, + returns: { + r1: { + line: 51, + type: 'bytes', + noDoc: true, + storage: StorageLocation.Memory, + }, + r2: { + line: 51, + type: 'bytes', + noDoc: true, + storage: StorageLocation.Memory, + }, + }, + }); + }); + }); + + describe('`BaseContract`', () => { + it('`baseContractMethod1`', () => { + assertMethodDocs('BaseContract.baseContractMethod1', { + line: 36, + visibility: Visibility.Internal, + params: { + p1: { + line: 39, + type: 'bytes', + storage: StorageLocation.Memory, + }, + p2: { + line: 39, + type: 'bytes32', + }, + }, + returns: { + '0': { + line: 41, + type: 'InterfaceContract.InterfaceStruct', + storage: StorageLocation.Memory, + }, + }, + }); + }); + + it('`baseContractField1`', () => { + assertMethodDocs('BaseContract.baseContractField1', { + line: 26, + visibility: Visibility.External, + params: { + '0': { + line: 26, + type: 'bytes32', + }, + '1': { + line: 26, + type: 'address', + }, + }, + returns: { + '0': { + line: 26, + type: 'InterfaceContract.InterfaceStruct', + storage: StorageLocation.Memory, + }, + }, + }); + }); + + it('`baseContractField2`', () => { + assertMethodDocs('BaseContract.baseContractField2', { + line: 30, + visibility: Visibility.External, + params: { + '0': { + line: 30, + type: 'uint256', + }, + }, + returns: { + '0': { + noDoc: true, + line: 30, + type: 'bytes32', + }, + }, + }); + }); + + it('`baseContractField3`', () => { + // This field is private so no method should exist for it. + expect(docs.contracts.TestContract.events.find(e => e.name === 'baseContractField3')).to.eq(undefined); + }); + }); + }); + + describe('events', () => { + interface ExpectedEventProps { + noDoc?: boolean; + line: number; + params?: { + [name: string]: { + noDoc?: boolean; + line: number; + type: string; + indexed?: boolean; + }; + }; + } + + function assertEventDocs(fullEventName: string, props: ExpectedEventProps): void { + const [contractName, eventName] = fullEventName.split('.'); + const e = docs.contracts[contractName].events.find(_e => _e.name === eventName) as MethodDocs; + { + const doc = props.noDoc ? '' : createDocString(eventName); + expect(e).to.exist(''); + expect(e.contract).to.eq(contractName); + expect(e.doc).to.eq(doc); + } + const params = props.params || {}; + expect(Object.keys(e.parameters), 'number of parameters').to.be.length(Object.keys(params).length); + for (const [paramName, paramDoc] of Object.entries(params)) { + const actualParam = e.parameters[paramName]; + const doc = paramDoc.noDoc ? '' : createDocString(paramName); + const isIndexed = paramDoc.indexed === undefined ? false : paramDoc.indexed; + expect(actualParam).to.exist(''); + expect(actualParam.doc).to.eq(doc); + expect(actualParam.line).to.eq(paramDoc.line); + expect(actualParam.indexed).to.eq(isIndexed); + expect(actualParam.type).to.eq(paramDoc.type); + } + } + + describe('`BaseContract`', () => { + it('`BaseContractEvent1`', () => { + assertEventDocs('BaseContract.BaseContractEvent1', { + line: 14, + params: { + p1: { + line: 14, + type: 'address', + indexed: true, + }, + p2: { + line: 14, + type: 'InterfaceContract.InterfaceStruct', + }, + }, + }); + }); + + it('`BaseContractEvent2`', () => { + assertEventDocs('BaseContract.BaseContractEvent2', { + line: 16, + params: { + p1: { + line: 17, + type: 'uint256', + noDoc: true, + }, + p2: { + line: 18, + type: 'uint256', + indexed: true, + noDoc: true, + }, + }, + }); + }); + }); + }); + + describe('enums', () => { + interface ExpectedEnumProps { + noDoc?: boolean; + line: number; + values?: { + [name: string]: { + noDoc?: boolean; + line: number; + value: number; + }; + }; + } + + function assertEnumDocs(fullEnumName: string, props: ExpectedEnumProps): void { + const [contractName, enumName] = fullEnumName.split('.'); + const e = docs.contracts[contractName].enums[`${contractName}.${enumName}`]; + { + const doc = props.noDoc ? '' : createDocString(enumName); + expect(e).to.exist(''); + expect(e.contract).to.eq(contractName); + expect(e.doc).to.eq(doc); + } + const values = props.values || {}; + expect(Object.keys(e.values), 'number of values').to.be.length(Object.keys(values).length); + for (const [valueName, valueDoc] of Object.entries(values)) { + const actualValue = e.values[valueName]; + const doc = valueDoc.noDoc ? '' : createDocString(valueName); + expect(actualValue).to.exist(''); + expect(actualValue.doc).to.eq(doc); + expect(actualValue.line).to.eq(valueDoc.line); + expect(actualValue.value).to.eq(valueDoc.value); + } + } + + describe('`LibraryContract`', () => { + it('`LibraryContractEnum`', () => { + assertEnumDocs('LibraryContract.LibraryContractEnum', { + line: 9, + values: { + EnumMember1: { + line: 10, + value: 0, + }, + EnumMember2: { + line: 11, + value: 1, + }, + EnumMember3: { + line: 13, + value: 2, + }, + EnumMember4: { + noDoc: true, + line: 14, + value: 3, + }, + }, + }); + }); + }); + }); + + describe('structs', () => { + interface ExpectedStructProps { + noDoc?: boolean; + line: number; + fields?: { + [name: string]: { + noDoc?: boolean; + line: number; + type: string; + order: number; + }; + }; + } + + function assertStructDocs(fullStructName: string, props: ExpectedStructProps): void { + const [contractName, structName] = fullStructName.split('.'); + const s = docs.contracts[contractName].structs[`${contractName}.${structName}`]; + { + const doc = props.noDoc ? '' : createDocString(structName); + expect(s).to.exist(''); + expect(s.contract).to.eq(contractName); + expect(s.doc).to.eq(doc); + } + const fields = props.fields || {}; + expect(Object.keys(s.fields), 'number of fields').to.be.length(Object.keys(fields).length); + for (const [fieldName, fieldDoc] of Object.entries(fields)) { + const actualField = s.fields[fieldName]; + const doc = fieldDoc.noDoc ? '' : createDocString(fieldName); + expect(actualField).to.exist(''); + expect(actualField.doc).to.eq(doc); + expect(actualField.line).to.eq(fieldDoc.line); + expect(actualField.type).to.eq(fieldDoc.type); + expect(actualField.storageLocation).to.eq(StorageLocation.Default); + expect(actualField.indexed).to.eq(false); + } + } + + describe('`LibraryContract`', () => { + it('`LibraryStruct`', () => { + assertStructDocs('LibraryContract.LibraryStruct', { + line: 19, + fields: { + structField: { + line: 20, + type: 'mapping(bytes32 => address)', + order: 0, + }, + }, + }); + }); + }); + + describe('`InterfaceContract`', () => { + it('`InterfaceStruct`', () => { + assertStructDocs('InterfaceContract.InterfaceStruct', { + line: 9, + fields: { + structField1: { + line: 9, + type: 'address', + order: 0, + }, + structField2: { + line: 10, + type: 'uint256', + order: 1, + }, + structField3: { + line: 12, + type: 'bytes32', + order: 2, + }, + }, + }); + }); + }); + }); +}); +// tslint:disable: max-file-line-count diff --git a/packages/sol-doc/test/fixtures/contracts/MultipleReturnValues.sol b/packages/sol-doc/test/fixtures/contracts/MultipleReturnValues.sol deleted file mode 100644 index 1e898622cf..0000000000 --- a/packages/sol-doc/test/fixtures/contracts/MultipleReturnValues.sol +++ /dev/null @@ -1,7 +0,0 @@ -pragma solidity ^0.4.24; - -contract MultipleReturnValues { - function methodWithMultipleReturnValues() public pure returns(int, int) { - return (0, 0); - } -} diff --git a/packages/sol-doc/test/fixtures/contracts/NatspecEverything.sol b/packages/sol-doc/test/fixtures/contracts/NatspecEverything.sol deleted file mode 100644 index c6ad3db812..0000000000 --- a/packages/sol-doc/test/fixtures/contracts/NatspecEverything.sol +++ /dev/null @@ -1,40 +0,0 @@ -pragma solidity ^0.4.24; - -/// @title Contract Title -/// @dev This is a very long documentation comment at the contract level. -/// It actually spans multiple lines, too. -contract NatspecEverything { - int d; - - /// @dev Constructor @dev - /// @param p Constructor @param - constructor(int p) public { d = p; } - - /// @notice publicMethod @notice - /// @dev publicMethod @dev - /// @param p publicMethod @param - /// @return publicMethod @return - function publicMethod(int p) public pure returns(int r) { return p; } - - /// @dev Fallback @dev - function () public {} - - /// @notice externalMethod @notice - /// @dev externalMethod @dev - /// @param p externalMethod @param - /// @return externalMethod @return - function externalMethod(int p) external pure returns(int r) { return p; } - - /// @dev Here is a really long developer documentation comment, which spans - /// multiple lines, for the purposes of making sure that broken lines are - /// consolidated into one devdoc comment. - function methodWithLongDevdoc(int p) public pure returns(int) { return p; } - - /// @dev AnEvent @dev - /// @param p on this event is an integer. - event AnEvent(int p); - - /// @dev methodWithSolhintDirective @dev - // solhint-disable no-empty-blocks - function methodWithSolhintDirective() public pure {} -} diff --git a/packages/sol-doc/test/fixtures/contracts/StructParamAndReturn.sol b/packages/sol-doc/test/fixtures/contracts/StructParamAndReturn.sol deleted file mode 100644 index b9a7ccdbcc..0000000000 --- a/packages/sol-doc/test/fixtures/contracts/StructParamAndReturn.sol +++ /dev/null @@ -1,18 +0,0 @@ -pragma solidity 0.4.24; -pragma experimental ABIEncoderV2; - - -contract StructParamAndReturn { - - struct Stuff { - address anAddress; - uint256 aNumber; - } - - /// @dev DEV_COMMENT - /// @param stuff STUFF_COMMENT - /// @return RETURN_COMMENT - function methodWithStructParamAndReturn(Stuff stuff) public pure returns(Stuff) { - return stuff; - } -} diff --git a/packages/sol-doc/test/fixtures/contracts/TokenTransferProxy.sol b/packages/sol-doc/test/fixtures/contracts/TokenTransferProxy.sol deleted file mode 100644 index fc1cc029c2..0000000000 --- a/packages/sol-doc/test/fixtures/contracts/TokenTransferProxy.sol +++ /dev/null @@ -1,115 +0,0 @@ -/* - - Copyright 2019 ZeroEx Intl. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -*/ - -pragma solidity ^0.4.14; - -import { Ownable } from "zeppelin-solidity/contracts/ownership/Ownable.sol"; -import { ERC20 as Token } from "zeppelin-solidity/contracts/token/ERC20/ERC20.sol"; - -/// @title TokenTransferProxy - Transfers tokens on behalf of contracts that have been approved via decentralized governance. -/// @author Amir Bandeali - , Will Warren - -contract TokenTransferProxy is Ownable { - - /// @dev Only authorized addresses can invoke functions with this modifier. - modifier onlyAuthorized { - require(authorized[msg.sender]); - _; - } - - modifier targetAuthorized(address target) { - require(authorized[target]); - _; - } - - modifier targetNotAuthorized(address target) { - require(!authorized[target]); - _; - } - - mapping (address => bool) public authorized; - address[] public authorities; - - event LogAuthorizedAddressAdded(address indexed target, address indexed caller); - event LogAuthorizedAddressRemoved(address indexed target, address indexed caller); - - /* - * Public functions - */ - - /// @dev Authorizes an address. - /// @param target Address to authorize. - function addAuthorizedAddress(address target) - public - onlyOwner - targetNotAuthorized(target) - { - authorized[target] = true; - authorities.push(target); - LogAuthorizedAddressAdded(target, msg.sender); - } - - /// @dev Removes authorizion of an address. - /// @param target Address to remove authorization from. - function removeAuthorizedAddress(address target) - public - onlyOwner - targetAuthorized(target) - { - delete authorized[target]; - for (uint i = 0; i < authorities.length; i++) { - if (authorities[i] == target) { - authorities[i] = authorities[authorities.length - 1]; - authorities.length -= 1; - break; - } - } - LogAuthorizedAddressRemoved(target, msg.sender); - } - - /// @dev Calls into ERC20 Token contract, invoking transferFrom. - /// @param token Address of token to transfer. - /// @param from Address to transfer token from. - /// @param to Address to transfer token to. - /// @param value Amount of token to transfer. - /// @return Success of transfer. - function transferFrom( - address token, - address from, - address to, - uint value) - public - onlyAuthorized - returns (bool) - { - return Token(token).transferFrom(from, to, value); - } - - /* - * Public constant functions - */ - - /// @dev Gets all authorized addresses. - /// @return Array of authorized addresses. - function getAuthorizedAddresses() - public - constant - returns (address[]) - { - return authorities; - } -} diff --git a/packages/sol-doc/test/fixtures/contracts/TokenTransferProxyNoDevdoc.sol b/packages/sol-doc/test/fixtures/contracts/TokenTransferProxyNoDevdoc.sol deleted file mode 100644 index c2ecc49884..0000000000 --- a/packages/sol-doc/test/fixtures/contracts/TokenTransferProxyNoDevdoc.sol +++ /dev/null @@ -1,100 +0,0 @@ -/* - - Copyright 2019 ZeroEx Intl. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -*/ - -pragma solidity ^0.4.14; - -import { Ownable } from "zeppelin-solidity/contracts/ownership/Ownable.sol"; -import { ERC20 as Token } from "zeppelin-solidity/contracts/token/ERC20/ERC20.sol"; - -contract TokenTransferProxyNoDevdoc is Ownable { - - modifier onlyAuthorized { - require(authorized[msg.sender]); - _; - } - - modifier targetAuthorized(address target) { - require(authorized[target]); - _; - } - - modifier targetNotAuthorized(address target) { - require(!authorized[target]); - _; - } - - mapping (address => bool) public authorized; - address[] public authorities; - - event LogAuthorizedAddressAdded(address indexed target, address indexed caller); - event LogAuthorizedAddressRemoved(address indexed target, address indexed caller); - - /* - * Public functions - */ - - function addAuthorizedAddress(address target) - public - onlyOwner - targetNotAuthorized(target) - { - authorized[target] = true; - authorities.push(target); - LogAuthorizedAddressAdded(target, msg.sender); - } - - function removeAuthorizedAddress(address target) - public - onlyOwner - targetAuthorized(target) - { - delete authorized[target]; - for (uint i = 0; i < authorities.length; i++) { - if (authorities[i] == target) { - authorities[i] = authorities[authorities.length - 1]; - authorities.length -= 1; - break; - } - } - LogAuthorizedAddressRemoved(target, msg.sender); - } - - function transferFrom( - address token, - address from, - address to, - uint value) - public - onlyAuthorized - returns (bool) - { - return Token(token).transferFrom(from, to, value); - } - - /* - * Public constant functions - */ - - function getAuthorizedAddresses() - public - constant - returns (address[]) - { - return authorities; - } -} diff --git a/packages/sol-doc/test/gen_md_test.ts b/packages/sol-doc/test/gen_md_test.ts new file mode 100644 index 0000000000..cbee7c88c0 --- /dev/null +++ b/packages/sol-doc/test/gen_md_test.ts @@ -0,0 +1,114 @@ +import { chaiSetup } from '@0x/dev-utils'; +import { expect } from 'chai'; +import * as _ from 'lodash'; + +import { FunctionKind, SolidityDocs } from '../src/extract_docs'; +import { generateMarkdownFromDocs } from '../src/gen_md'; + +import { randomContract, randomWord } from './utils/random_docs'; + +chaiSetup.configure(); + +// tslint:disable: custom-no-magic-numbers +describe('generateMarkdownFromDocs()', () => { + const URL_PREFIX = randomWord(); + const DOCS: SolidityDocs = { + contracts: { + ..._.mapValues( + _.groupBy( + _.times(_.random(2, 8), () => + ((name: string) => ({ name, ...randomContract(name) }))(`${randomWord()}Contract`), + ), + 'name', + ), + g => g[0], + ), + }, + }; + + let md: string; + let mdLines: string[]; + + function getMarkdownHeaders(level: number): string[] { + const lines = mdLines.filter(line => new RegExp(`^\\s*#{${level}}[^#]`).test(line)); + // tslint:disable-next-line: no-non-null-assertion + return lines.map(line => /^\s*#+\s*(.+?)\s*$/.exec(line)![1]); + } + + function getMarkdownLinks(): string[] { + const links: string[] = []; + for (const line of mdLines) { + const re = /\[[^\]]+\]\(([^)]+)\)/g; + let m: string[] | undefined | null; + do { + m = re.exec(line); + if (m) { + links.push(m[1]); + } + } while (m); + } + return links; + } + + before(() => { + md = generateMarkdownFromDocs(DOCS, { urlPrefix: URL_PREFIX }); + mdLines = md.split('\n'); + }); + + it('generates entries for all contracts', () => { + const headers = getMarkdownHeaders(1); + for (const [contractName, contract] of Object.entries(DOCS.contracts)) { + expect(headers).to.include(`${contract.kind} \`${contractName}\``); + } + }); + + it('generates entries for all enums', () => { + const headers = getMarkdownHeaders(3); + for (const contract of Object.values(DOCS.contracts)) { + for (const enumName of Object.keys(contract.enums)) { + expect(headers).to.include(`\`${enumName}\``); + } + } + }); + + it('generates entries for all structs', () => { + const headers = getMarkdownHeaders(3); + for (const contract of Object.values(DOCS.contracts)) { + for (const structName of Object.keys(contract.structs)) { + expect(headers).to.include(`\`${structName}\``); + } + } + }); + + it('generates entries for all events', () => { + const headers = getMarkdownHeaders(3); + for (const contract of Object.values(DOCS.contracts)) { + for (const event of contract.events) { + expect(headers).to.include(`\`${event.name}\``); + } + } + }); + + it('generates entries for all methods', () => { + const headers = getMarkdownHeaders(3); + for (const contract of Object.values(DOCS.contracts)) { + for (const method of contract.methods) { + if (method.kind === FunctionKind.Fallback) { + expect(headers).to.include(`\`\``); + } else if (method.kind === FunctionKind.Constructor) { + expect(headers).to.include(`\`constructor\``); + } else { + expect(headers).to.include(`\`${method.name}\``); + } + } + } + }); + + it('prefixes all URLs with the prefix', () => { + const urls = getMarkdownLinks(); + for (const url of urls) { + expect(url.startsWith(URL_PREFIX)).to.be.true(); + } + }); +}); +// tslint:disable: max-file-line-count diff --git a/packages/sol-doc/test/inputs/BaseContract.sol b/packages/sol-doc/test/inputs/BaseContract.sol new file mode 100644 index 0000000000..eba64abb7b --- /dev/null +++ b/packages/sol-doc/test/inputs/BaseContract.sol @@ -0,0 +1,43 @@ +pragma solidity ^0.5; +pragma experimental ABIEncoderV2; + +import "./InterfaceContract.sol"; +import "./LibraryContract.sol"; + + +/// @dev Documentation for `BaseContract`. +contract BaseContract { + + /// @dev Documentation for `BaseContractEvent1`. + /// @param p1 Documentation for `p1`. + /// @param p2 Documentation for `p2`. + event BaseContractEvent1(address indexed p1, InterfaceContract.InterfaceStruct p2); + // Documentation for `BaseContractEvent2`. + event BaseContractEvent2( + uint256 p1, + uint256 indexed p2 + ); + + + /// @dev Documentation for `baseContractField1`. + /// @param 1 Documentation for `1`. + /// @param 0 Documentation for `0`. + /// @return 0 Documentation for `0`. + mapping (bytes32 => mapping(address => InterfaceContract.InterfaceStruct)) public baseContractField1; + + /// @dev Documentation for `baseContractField2`. + /// @param 0 Documentation for `0`. + bytes32[] public baseContractField2; + + /// @dev Documentation for `_baseContractField3`. + uint256 private _baseContractField3; + + /// @dev Documentation for `baseContractMethod1`. + /// @param p1 Documentation for `p1`. + /// @param p2 Documentation for `p2`. + /// @return 0 Documentation for `0`. + function baseContractMethod1(bytes memory p1, bytes32 p2) + internal + returns (InterfaceContract.InterfaceStruct memory) + {} +} diff --git a/packages/sol-doc/test/inputs/InterfaceContract.sol b/packages/sol-doc/test/inputs/InterfaceContract.sol new file mode 100644 index 0000000000..bdc87b38fa --- /dev/null +++ b/packages/sol-doc/test/inputs/InterfaceContract.sol @@ -0,0 +1,14 @@ +pragma solidity ^0.5; + + +contract InterfaceContract { + + /// @dev Documentation for `InterfaceStruct`. + /// @param structField2 Documentation for `structField2`. + struct InterfaceStruct { + address structField1; // Documentation for `structField1`. + uint256 structField2; // Stuff to ignore. + // Documentation for `structField3`. + bytes32 structField3; + } +} diff --git a/packages/sol-doc/test/inputs/LibraryContract.sol b/packages/sol-doc/test/inputs/LibraryContract.sol new file mode 100644 index 0000000000..08e5b2547b --- /dev/null +++ b/packages/sol-doc/test/inputs/LibraryContract.sol @@ -0,0 +1,22 @@ +pragma solidity ^0.5; + + +/// @dev Documentation for `LibraryContract`. +contract LibraryContract { + + /// @dev Documentation for `LibraryContractEnum`. + /// @param EnumMember1 Documentation for `EnumMember1`. + enum LibraryContractEnum { + EnumMember1, + EnumMember2, // Documentation for `EnumMember2`. + // Documentation for `EnumMember3`. + EnumMember3, + EnumMember4 + } + + /// @dev Documentation for `LibraryStruct`. + /// @param structField Documentation for `structField`. + struct LibraryStruct { + mapping (bytes32 => address) structField; + } +} diff --git a/packages/sol-doc/test/inputs/TestContract.sol b/packages/sol-doc/test/inputs/TestContract.sol new file mode 100644 index 0000000000..a992682ad5 --- /dev/null +++ b/packages/sol-doc/test/inputs/TestContract.sol @@ -0,0 +1,55 @@ +pragma solidity ^0.5; +pragma experimental ABIEncoderV2; + +import "./InterfaceContract.sol"; +import "./LibraryContract.sol"; +import "./BaseContract.sol"; + + +/// @dev Documentation for `TestContract`. +contract TestContract is + BaseContract, + InterfaceContract +{ + /// @dev Documentation for `testContractMethod1`. + function testContractMethod1() public {} + + // Stuff to ignore. + /// @dev Documentation for `testContractMethod2`. + /// @param p2 Documentation for `p2`. + /// @param p1 Documentation for `p1`. + /// @param p3 Documentation for `p3`. + /// @return r1 Documentation for `r1`. + function testContractMethod2( + address p1, + uint256 p2, + LibraryContract.LibraryContractEnum p3 + ) + internal + returns (int32 r1) + { + return r1; + } + + /// @dev Documentation for `testContractMethod3`. + /// @param p1 Documentation for `p1`. + /// @return r1 Documentation for `r1`. + function testContractMethod3(InterfaceContract.InterfaceStruct calldata p1) + external + returns (bytes32[][] memory r1) + { + return r1; + } + + // Documentation for `testContractMethod4`. + function testContractMethod4( + LibraryContract.LibraryStruct[] storage p1, + InterfaceContract.InterfaceStruct[] memory p2, + bytes[] memory p3 + ) + private + returns (bytes memory r1, bytes memory r2) + { + return (r1, r2); + } +} diff --git a/packages/sol-doc/test/solidity_doc_generator_test.ts b/packages/sol-doc/test/solidity_doc_generator_test.ts deleted file mode 100644 index 99b39e722c..0000000000 --- a/packages/sol-doc/test/solidity_doc_generator_test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import * as _ from 'lodash'; - -import * as chai from 'chai'; -import 'mocha'; - -import { DocAgnosticFormat, Event, SolidityMethod } from '@0x/types'; - -import { SolDoc } from '../src/sol_doc'; - -import { chaiSetup } from './util/chai_setup'; - -chaiSetup.configure(); -const expect = chai.expect; -const solDoc = new SolDoc(); - -describe('#SolidityDocGenerator', () => { - it('should generate a doc object that matches the devdoc-free TokenTransferProxy fixture', async () => { - const doc = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, [ - 'TokenTransferProxyNoDevdoc', - ]); - expect(doc).to.not.be.undefined(); - - verifyTokenTransferProxyABIIsDocumented(doc, 'TokenTransferProxyNoDevdoc'); - }); - const docPromises: Array> = [ - solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`), - solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, []), - ]; - docPromises.forEach(docPromise => { - it('should generate a doc object that matches the TokenTransferProxy fixture with its dependencies', async () => { - const doc = await docPromise; - expect(doc).to.not.be.undefined(); - - verifyTokenTransferProxyAndDepsABIsAreDocumented(doc, 'TokenTransferProxy'); - - let addAuthorizedAddressMethod: SolidityMethod | undefined; - for (const method of doc.TokenTransferProxy.methods) { - if (method.name === 'addAuthorizedAddress') { - addAuthorizedAddressMethod = method; - } - } - const tokenTransferProxyAddAuthorizedAddressComment = 'Authorizes an address.'; - expect((addAuthorizedAddressMethod as SolidityMethod).comment).to.equal( - tokenTransferProxyAddAuthorizedAddressComment, - ); - - const expectedParamComment = 'Address to authorize.'; - expect((addAuthorizedAddressMethod as SolidityMethod).parameters[0].comment).to.equal(expectedParamComment); - }); - }); - it('should generate a doc object that matches the TokenTransferProxy fixture', async () => { - const doc: DocAgnosticFormat = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, [ - 'TokenTransferProxy', - ]); - verifyTokenTransferProxyABIIsDocumented(doc, 'TokenTransferProxy'); - }); - describe('when processing all the permutations of devdoc stuff that we use in our contracts', () => { - let doc: DocAgnosticFormat; - before(async () => { - doc = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, ['NatspecEverything']); - expect(doc).to.not.be.undefined(); - expect(doc.NatspecEverything).to.not.be.undefined(); - }); - it('should emit the contract @title as its comment', () => { - expect(doc.NatspecEverything.comment).to.equal('Contract Title'); - }); - describe('should emit public method documentation for', () => { - let methodDoc: SolidityMethod; - before(() => { - // tslint:disable-next-line:no-unnecessary-type-assertion - methodDoc = doc.NatspecEverything.methods.find(method => { - return method.name === 'publicMethod'; - }) as SolidityMethod; - if (methodDoc === undefined) { - throw new Error('publicMethod not found'); - } - }); - it('method name', () => { - expect(methodDoc.name).to.equal('publicMethod'); - }); - it('method comment', () => { - expect(methodDoc.comment).to.equal('publicMethod @dev'); - }); - it('parameter name', () => { - expect(methodDoc.parameters[0].name).to.equal('p'); - }); - it('parameter comment', () => { - expect(methodDoc.parameters[0].comment).to.equal('publicMethod @param'); - }); - it('return type', () => { - expect(methodDoc.returnType.name).to.equal('int256'); - }); - it('return comment', () => { - expect(methodDoc.returnComment).to.equal('publicMethod @return'); - }); - }); - describe('should emit external method documentation for', () => { - let methodDoc: SolidityMethod; - before(() => { - // tslint:disable-next-line:no-unnecessary-type-assertion - methodDoc = doc.NatspecEverything.methods.find(method => { - return method.name === 'externalMethod'; - }) as SolidityMethod; - if (methodDoc === undefined) { - throw new Error('externalMethod not found'); - } - }); - it('method name', () => { - expect(methodDoc.name).to.equal('externalMethod'); - }); - it('method comment', () => { - expect(methodDoc.comment).to.equal('externalMethod @dev'); - }); - it('parameter name', () => { - expect(methodDoc.parameters[0].name).to.equal('p'); - }); - it('parameter comment', () => { - expect(methodDoc.parameters[0].comment).to.equal('externalMethod @param'); - }); - it('return type', () => { - expect(methodDoc.returnType.name).to.equal('int256'); - }); - it('return comment', () => { - expect(methodDoc.returnComment).to.equal('externalMethod @return'); - }); - }); - it('should not truncate a multi-line devdoc comment', () => { - // tslint:disable-next-line:no-unnecessary-type-assertion - const methodDoc: SolidityMethod = doc.NatspecEverything.methods.find(method => { - return method.name === 'methodWithLongDevdoc'; - }) as SolidityMethod; - if (methodDoc === undefined) { - throw new Error('methodWithLongDevdoc not found'); - } - expect(methodDoc.comment).to.equal( - 'Here is a really long developer documentation comment, which spans multiple lines, for the purposes of making sure that broken lines are consolidated into one devdoc comment.', - ); - }); - describe('should emit event documentation for', () => { - let eventDoc: Event; - before(() => { - eventDoc = (doc.NatspecEverything.events as Event[])[0]; - }); - it('event name', () => { - expect(eventDoc.name).to.equal('AnEvent'); - }); - it('parameter name', () => { - expect(eventDoc.eventArgs[0].name).to.equal('p'); - }); - }); - it('should not let solhint directives obscure natspec content', () => { - // tslint:disable-next-line:no-unnecessary-type-assertion - const methodDoc: SolidityMethod = doc.NatspecEverything.methods.find(method => { - return method.name === 'methodWithSolhintDirective'; - }) as SolidityMethod; - if (methodDoc === undefined) { - throw new Error('methodWithSolhintDirective not found'); - } - expect(methodDoc.comment).to.equal('methodWithSolhintDirective @dev'); - }); - }); - it('should document a method that returns multiple values', async () => { - const doc = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, [ - 'MultipleReturnValues', - ]); - expect(doc.MultipleReturnValues).to.not.be.undefined(); - expect(doc.MultipleReturnValues.methods).to.not.be.undefined(); - let methodWithMultipleReturnValues: SolidityMethod | undefined; - for (const method of doc.MultipleReturnValues.methods) { - if (method.name === 'methodWithMultipleReturnValues') { - methodWithMultipleReturnValues = method; - } - } - if (methodWithMultipleReturnValues === undefined) { - throw new Error('method should not be undefined'); - } - const returnType = methodWithMultipleReturnValues.returnType; - expect(returnType.typeDocType).to.equal('tuple'); - if (returnType.tupleElements === undefined) { - throw new Error('returnType.tupleElements should not be undefined'); - } - expect(returnType.tupleElements.length).to.equal(2); - }); - it('should document a method that has a struct param and return value', async () => { - const doc = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, [ - 'StructParamAndReturn', - ]); - expect(doc.StructParamAndReturn).to.not.be.undefined(); - expect(doc.StructParamAndReturn.methods).to.not.be.undefined(); - let methodWithStructParamAndReturn: SolidityMethod | undefined; - for (const method of doc.StructParamAndReturn.methods) { - if (method.name === 'methodWithStructParamAndReturn') { - methodWithStructParamAndReturn = method; - } - } - if (methodWithStructParamAndReturn === undefined) { - throw new Error('method should not be undefined'); - } - /** - * Solc maps devDoc comments to methods using a method signature. If we incorrectly - * generate the methodSignatures, the devDoc comments won't be correctly associated - * with their methods and they won't show up in the output. By checking that the comments - * are included for a method with structs as params/returns, we are sure that the methodSignature - * generation is correct for this case. - */ - expect(methodWithStructParamAndReturn.comment).to.be.equal('DEV_COMMENT'); - expect(methodWithStructParamAndReturn.returnComment).to.be.equal('RETURN_COMMENT'); - expect(methodWithStructParamAndReturn.parameters[0].comment).to.be.equal('STUFF_COMMENT'); - }); - it('should document the structs included in a contract', async () => { - const doc = await solDoc.generateSolDocAsync(`${__dirname}/../../test/fixtures/contracts`, [ - 'StructParamAndReturn', - ]); - expect(doc.structs).to.not.be.undefined(); - expect(doc.structs.types.length).to.be.equal(1); - }); -}); - -function verifyTokenTransferProxyABIIsDocumented(doc: DocAgnosticFormat, contractName: string): void { - expect(doc[contractName]).to.not.be.undefined(); - expect(doc[contractName].constructors).to.not.be.undefined(); - const tokenTransferProxyConstructorCount = 0; - const tokenTransferProxyMethodCount = 8; - const tokenTransferProxyEventCount = 3; - expect(doc[contractName].constructors.length).to.equal(tokenTransferProxyConstructorCount); - expect(doc[contractName].methods.length).to.equal(tokenTransferProxyMethodCount); - const events = doc[contractName].events; - if (events === undefined) { - throw new Error('events should never be undefined'); - } - expect(events.length).to.equal(tokenTransferProxyEventCount); -} - -function verifyTokenTransferProxyAndDepsABIsAreDocumented(doc: DocAgnosticFormat, contractName: string): void { - verifyTokenTransferProxyABIIsDocumented(doc, contractName); - - expect(doc.ERC20).to.not.be.undefined(); - expect(doc.ERC20.constructors).to.not.be.undefined(); - expect(doc.ERC20.methods).to.not.be.undefined(); - const erc20ConstructorCount = 0; - const erc20MethodCount = 6; - const erc20EventCount = 2; - expect(doc.ERC20.constructors.length).to.equal(erc20ConstructorCount); - expect(doc.ERC20.methods.length).to.equal(erc20MethodCount); - if (doc.ERC20.events === undefined) { - throw new Error('events should never be undefined'); - } - expect(doc.ERC20.events.length).to.equal(erc20EventCount); - - expect(doc.ERC20Basic).to.not.be.undefined(); - expect(doc.ERC20Basic.constructors).to.not.be.undefined(); - expect(doc.ERC20Basic.methods).to.not.be.undefined(); - const erc20BasicConstructorCount = 0; - const erc20BasicMethodCount = 3; - const erc20BasicEventCount = 1; - expect(doc.ERC20Basic.constructors.length).to.equal(erc20BasicConstructorCount); - expect(doc.ERC20Basic.methods.length).to.equal(erc20BasicMethodCount); - if (doc.ERC20Basic.events === undefined) { - throw new Error('events should never be undefined'); - } - expect(doc.ERC20Basic.events.length).to.equal(erc20BasicEventCount); - - let addAuthorizedAddressMethod: SolidityMethod | undefined; - for (const method of doc[contractName].methods) { - if (method.name === 'addAuthorizedAddress') { - addAuthorizedAddressMethod = method; - } - } - expect( - addAuthorizedAddressMethod, - `method addAuthorizedAddress not found in ${JSON.stringify(doc[contractName].methods)}`, - ).to.not.be.undefined(); -} diff --git a/packages/sol-doc/test/transform_docs_test.ts b/packages/sol-doc/test/transform_docs_test.ts new file mode 100644 index 0000000000..7b20932c13 --- /dev/null +++ b/packages/sol-doc/test/transform_docs_test.ts @@ -0,0 +1,225 @@ +import { chaiSetup } from '@0x/dev-utils'; +import { expect } from 'chai'; +import * as _ from 'lodash'; + +import { ContractKind, EventDocs, FunctionKind, MethodDocs, SolidityDocs, Visibility } from '../src/extract_docs'; +import { transformDocs } from '../src/transform_docs'; + +import { + randomContract, + randomEnum, + randomEvent, + randomMethod, + randomParameter, + randomStruct, + randomWord, +} from './utils/random_docs'; + +chaiSetup.configure(); + +// tslint:disable: custom-no-magic-numbers +describe('transformDocs()', () => { + const INTERFACE_CONTRACT = 'InterfaceContract'; + const TEST_CONTRACT = 'TestContract'; + const BASE_CONTRACT = 'BaseContract'; + const OTHER_CONTRACT = 'OtherContract'; + const LIBRARY_CONTRACT = 'LibraryContract'; + const LIBRARY_EVENT = 'LibraryContract.LibraryEvent'; + const INTERFACE_EVENT = 'InterfaceContract.InterfaceEvent'; + const BASE_CONTRACT_EVENT = 'BaseContract.BaseContractEvent'; + const LIBRARY_ENUM = 'LibraryContract.LibraryEnum'; + const INTERFACE_ENUM = 'InterfaceContract.InterfaceEnum'; + const BASE_CONTRACT_ENUM = 'BaseContract.BaseContractEnum'; + const LIBRARY_STRUCT = 'LibraryContract.LibraryStruct'; + const INTERFACE_STRUCT = 'InterfaceContract.InterfaceStruct'; + const BASE_CONTRACT_STRUCT = 'BaseContract.BaseContractStruct'; + const OTHER_CONTRACT_STRUCT = 'OtherContract.OtherContractStruct'; + const INPUT_DOCS: SolidityDocs = { + contracts: { + [LIBRARY_CONTRACT]: _.merge(randomContract(LIBRARY_CONTRACT, { kind: ContractKind.Library }), { + events: { + [LIBRARY_EVENT]: randomEvent({ contract: LIBRARY_CONTRACT }), + }, + structs: { + [LIBRARY_STRUCT]: randomStruct({ contract: LIBRARY_CONTRACT }), + }, + enums: { + [LIBRARY_ENUM]: randomEnum({ contract: LIBRARY_CONTRACT }), + }, + }), + [INTERFACE_CONTRACT]: _.merge(randomContract(INTERFACE_CONTRACT, { kind: ContractKind.Interface }), { + events: { + [INTERFACE_EVENT]: randomEvent({ contract: INTERFACE_CONTRACT }), + }, + structs: { + [INTERFACE_STRUCT]: randomStruct({ contract: INTERFACE_CONTRACT }), + }, + enums: { + [INTERFACE_ENUM]: randomEnum({ contract: INTERFACE_CONTRACT }), + }, + }), + [BASE_CONTRACT]: _.merge(randomContract(BASE_CONTRACT, { kind: ContractKind.Contract }), { + events: { + [BASE_CONTRACT_EVENT]: randomEvent({ contract: BASE_CONTRACT }), + }, + structs: { + [BASE_CONTRACT_STRUCT]: randomStruct({ contract: BASE_CONTRACT }), + }, + enums: { + [BASE_CONTRACT_ENUM]: randomEnum({ contract: BASE_CONTRACT }), + }, + }), + [TEST_CONTRACT]: _.merge( + randomContract(TEST_CONTRACT, { kind: ContractKind.Contract, inherits: [BASE_CONTRACT] }), + { + methods: [ + randomMethod({ + contract: TEST_CONTRACT, + visibility: Visibility.External, + parameters: { + [randomWord()]: randomParameter(0, { type: INTERFACE_ENUM }), + }, + }), + randomMethod({ + contract: TEST_CONTRACT, + visibility: Visibility.Private, + parameters: { + [randomWord()]: randomParameter(0, { type: LIBRARY_STRUCT }), + }, + }), + ], + }, + ), + [OTHER_CONTRACT]: _.merge(randomContract(OTHER_CONTRACT, { kind: ContractKind.Contract }), { + structs: { + [OTHER_CONTRACT_STRUCT]: randomStruct({ + contract: OTHER_CONTRACT, + fields: { + [randomWord()]: randomParameter(0, { type: LIBRARY_ENUM }), + }, + }), + }, + methods: [ + randomMethod({ + contract: OTHER_CONTRACT, + visibility: Visibility.Public, + returns: { + [randomWord()]: randomParameter(0, { type: OTHER_CONTRACT_STRUCT }), + }, + }), + randomMethod({ + contract: OTHER_CONTRACT, + visibility: Visibility.Internal, + returns: { + [randomWord()]: randomParameter(0, { type: INTERFACE_STRUCT }), + }, + }), + ], + events: [ + randomEvent({ + contract: OTHER_CONTRACT, + parameters: { + [randomWord()]: randomParameter(0, { type: LIBRARY_STRUCT }), + }, + }), + ], + }), + }, + }; + + function getMethodId(method: MethodDocs): string { + if (method.kind === FunctionKind.Constructor) { + return 'constructor'; + } + return getEventId(method); + } + + function getEventId(method: EventDocs | MethodDocs): string { + const paramsTypes = Object.values(method.parameters).map(p => p.type); + return `${method.name}(${paramsTypes.join(',')})`; + } + + function getAllTypes(docs: SolidityDocs): string[] { + const allTypes: string[] = []; + for (const contract of Object.values(docs.contracts)) { + for (const structName of Object.keys(contract.structs)) { + allTypes.push(structName); + } + for (const enumName of Object.keys(contract.enums)) { + allTypes.push(enumName); + } + } + return allTypes; + } + + it('returns all contracts with no target contracts', () => { + const docs = transformDocs(INPUT_DOCS); + expect(Object.keys(docs.contracts)).to.deep.eq([ + LIBRARY_CONTRACT, + INTERFACE_CONTRACT, + BASE_CONTRACT, + TEST_CONTRACT, + OTHER_CONTRACT, + ]); + }); + + it('returns requested AND related contracts', () => { + const contracts = [TEST_CONTRACT, OTHER_CONTRACT]; + const docs = transformDocs(INPUT_DOCS, { contracts }); + expect(Object.keys(docs.contracts)).to.deep.eq([LIBRARY_CONTRACT, INTERFACE_CONTRACT, ...contracts]); + }); + + it('returns exposed and unexposed items by default', () => { + const contracts = [TEST_CONTRACT]; + const docs = transformDocs(INPUT_DOCS, { contracts }); + expect(Object.keys(docs.contracts)).to.deep.eq([LIBRARY_CONTRACT, INTERFACE_CONTRACT, ...contracts]); + const allTypes = getAllTypes(docs); + // Check for an exposed type. + expect(allTypes).to.include(INTERFACE_ENUM); + // Check for an unexposed type. + expect(allTypes).to.include(LIBRARY_STRUCT); + }); + + it('can hide unexposed items', () => { + const contracts = [OTHER_CONTRACT]; + const docs = transformDocs(INPUT_DOCS, { contracts, onlyExposed: true }); + expect(Object.keys(docs.contracts)).to.deep.eq([LIBRARY_CONTRACT, ...contracts]); + const allTypes = getAllTypes(docs); + // Check for an exposed type. + expect(allTypes).to.include(LIBRARY_ENUM); + // Check for an unexposed type. + expect(allTypes).to.not.include(INTERFACE_STRUCT); + }); + + describe('flattening', () => { + it('merges inherited methods', () => { + const docs = transformDocs(INPUT_DOCS, { contracts: [TEST_CONTRACT], flatten: true }); + const allMethods = _.uniqBy( + _.flatten( + [BASE_CONTRACT, TEST_CONTRACT].map(c => + INPUT_DOCS.contracts[c].methods.filter(m => m.visibility !== Visibility.Private), + ), + ), + m => getMethodId(m), + ); + const outputMethods = docs.contracts[TEST_CONTRACT].methods; + expect(outputMethods).to.length(allMethods.length); + for (const method of outputMethods) { + expect(allMethods.map(m => getMethodId(m))).to.include(getMethodId(method)); + } + }); + + it('merges inherited events', () => { + const docs = transformDocs(INPUT_DOCS, { contracts: [TEST_CONTRACT], flatten: true }); + const allEvents = _.uniqBy( + _.flatten([BASE_CONTRACT, TEST_CONTRACT].map(c => INPUT_DOCS.contracts[c].events)), + e => getEventId(e), + ); + const outputEvents = docs.contracts[TEST_CONTRACT].events; + expect(outputEvents).to.length(allEvents.length); + for (const event of outputEvents) { + expect(allEvents.map(m => getEventId(m))).to.include(getEventId(event)); + } + }); + }); +}); diff --git a/packages/sol-doc/test/util/chai_setup.ts b/packages/sol-doc/test/util/chai_setup.ts deleted file mode 100644 index 1a87330932..0000000000 --- a/packages/sol-doc/test/util/chai_setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import ChaiBigNumber = require('chai-bignumber'); -import * as dirtyChai from 'dirty-chai'; - -export const chaiSetup = { - configure(): void { - chai.config.includeStack = true; - chai.use(ChaiBigNumber()); - chai.use(dirtyChai); - chai.use(chaiAsPromised); - }, -}; diff --git a/packages/sol-doc/test/utils/random_docs.ts b/packages/sol-doc/test/utils/random_docs.ts new file mode 100644 index 0000000000..0d5f01d826 --- /dev/null +++ b/packages/sol-doc/test/utils/random_docs.ts @@ -0,0 +1,175 @@ +import * as _ from 'lodash'; + +import { + ContractDocs, + ContractKind, + DocumentedItem, + EnumDocs, + EnumValueDocs, + EventDocs, + FunctionKind, + MethodDocs, + ParamDocs, + ParamDocsMap, + StateMutability, + StorageLocation, + StructDocs, + Visibility, +} from '../../src/extract_docs'; + +// tslint:disable: custom-no-magic-numbers completed-docs +const LETTERS = _.times(26, n => String.fromCharCode('a'.charCodeAt(0) + n)); + +export function randomWord(maxLength: number = 13): string { + return _.sampleSize(LETTERS, _.random(1, maxLength)).join(''); +} + +export function randomSentence(): string { + const numWords = _.random(3, 64); + return _.capitalize( + _.times(numWords, () => randomWord()) + .join(' ') + .concat('.'), + ); +} + +export function randomDocs(): DocumentedItem { + return { + doc: randomSentence(), + line: _.random(1, 65536), + file: _.capitalize(randomWord()).concat('.sol'), + }; +} + +export function randomBoolean(): boolean { + return _.random(0, 1) === 1; +} + +export function randomType(): string { + return _.sampleSize(['uint256', 'bytes32', 'bool', 'uint32', 'int256', 'int64', 'uint8'], 1)[0]; +} + +export function randomStorageLocation(): StorageLocation { + return _.sampleSize([StorageLocation.Default, StorageLocation.Memory, StorageLocation.Storage])[0]; +} + +export function randomContractKind(): ContractKind { + return _.sampleSize([ContractKind.Contract, ContractKind.Interface, ContractKind.Library])[0]; +} + +export function randomMutability(): StateMutability { + return _.sampleSize([ + StateMutability.Nonpayable, + StateMutability.Payable, + StateMutability.Pure, + StateMutability.View, + ])[0]; +} + +export function randomVisibility(): Visibility { + return _.sampleSize([Visibility.External, Visibility.Internal, Visibility.Public, Visibility.Private])[0]; +} + +export function randomFunctionKind(): FunctionKind { + return _.sampleSize([FunctionKind.Constructor, FunctionKind.Fallback, FunctionKind.Function])[0]; +} + +export function randomParameters(): ParamDocsMap { + const numParams = _.random(0, 7); + return _.zipObject(_.times(numParams, () => randomWord()), _.times(numParams, idx => randomParameter(idx))); +} + +export function randomParameter(order: number, fields?: Partial): ParamDocs { + return { + ...randomDocs(), + type: randomType(), + indexed: randomBoolean(), + storageLocation: randomStorageLocation(), + order, + ...fields, + }; +} + +export function randomEvent(fields?: Partial): EventDocs { + return { + ...randomDocs(), + contract: `${randomWord()}Contract`, + name: `${randomWord()}Event`, + parameters: randomParameters(), + ...fields, + }; +} + +export function randomMethod(fields?: Partial): MethodDocs { + return { + ...randomDocs(), + contract: `${randomWord()}Contract`, + name: `${randomWord()}Method`, + kind: randomFunctionKind(), + isAccessor: randomBoolean(), + stateMutability: randomMutability(), + visibility: randomVisibility(), + returns: randomParameters(), + parameters: randomParameters(), + ...fields, + }; +} + +export function randomStruct(fields?: Partial): StructDocs { + return { + ...randomDocs(), + contract: `${randomWord()}Contract`, + fields: randomParameters(), + ...fields, + }; +} + +export function randomEnum(fields?: Partial): EnumDocs { + return { + ...randomDocs(), + contract: `${randomWord()}Contract`, + values: _.mapValues( + _.groupBy( + _.times(_.random(1, 8), i => ({ + ...randomDocs(), + value: i, + name: randomWord(), + })), + 'name', + ), + v => (_.omit(v[0], 'name') as any) as EnumValueDocs, + ), + ...fields, + }; +} + +export function randomContract(contractName: string, fields?: Partial): ContractDocs { + return { + ...randomDocs(), + kind: randomContractKind(), + inherits: [], + events: _.times(_.random(1, 4), () => randomEvent({ contract: contractName })), + methods: _.times(_.random(1, 4), () => randomMethod({ contract: contractName })), + structs: _.mapValues( + _.groupBy( + _.times(_.random(1, 4), () => ({ + ...randomStruct({ contract: contractName }), + name: `${randomWord()}Struct`, + })), + 'name', + ), + v => (_.omit(v[0], 'name') as any) as StructDocs, + ), + enums: _.mapValues( + _.groupBy( + _.times(_.random(1, 4), () => ({ + ...randomEnum({ contract: contractName }), + name: `${randomWord()}Enum`, + })), + 'name', + ), + v => (_.omit(v[0], 'name') as any) as EnumDocs, + ), + ...fields, + }; +}