generate-invariant-docs.ts 5.25 KB
Newer Older
1 2 3
import fs from 'fs'
import path from 'path'

4 5 6
const ROOT_DIR = path.join(__dirname, '..', '..')
const BASE_INVARIANTS_DIR = path.join(ROOT_DIR, 'test', 'invariants')
const BASE_DOCS_DIR = path.join(ROOT_DIR, 'invariant-docs')
7
const BASE_INVARIANT_GH_URL = '../test/invariants/'
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
const NATSPEC_INV = '@custom:invariant'

// Represents an invariant test contract
type Contract = {
  name: string
  fileName: string
  docs: InvariantDoc[]
}

// Represents the documentation of an invariant
type InvariantDoc = {
  header?: string
  desc?: string
  lineNo?: number
}

const writtenFiles = []

26
// Lazy-parses all test files in the `test/invariants` directory
27
// to generate documentation on all invariant tests.
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
const docGen = (dir: string): void => {
  // Grab all files within the invariants test dir
  const files = fs.readdirSync(dir)

  // Array to store all found invariant documentation comments.
  const docs: Contract[] = []

  for (const fileName of files) {
    // Read the contents of the invariant test file.
    const fileContents = fs.readFileSync(path.join(dir, fileName)).toString()

    // Split the file into individual lines and trim whitespace.
    const lines = fileContents.split('\n').map((line: string) => line.trim())

    // Create an object to store all invariant test docs for the current contract
Maurelian's avatar
Maurelian committed
43 44
    const name = fileName.replace('.t.sol', '')
    const contract: Contract = { name, fileName, docs: [] }
45 46 47 48 49 50 51

    let currentDoc: InvariantDoc

    // Loop through all lines to find comments.
    for (let i = 0; i < lines.length; i++) {
      let line = lines[i]

52 53 54 55 56 57 58 59
      // We have an invariant doc
      if (line.startsWith(`/// ${NATSPEC_INV}`)) {
        // Assign the header of the invariant doc.
        // TODO: Handle ambiguous case for `INVARIANT: ` prefix.
        currentDoc = {
          header: line.replace(`/// ${NATSPEC_INV}`, '').trim(),
          desc: '',
        }
60

61
        // If the header is multi-line, continue appending to the `currentDoc`'s header.
62
        line = lines[++i]
63 64 65 66 67 68 69 70
        while (line.startsWith(`///`) && line.trim() !== '///') {
          currentDoc.header += ` ${line.replace(`///`, '').trim()}`
          line = lines[++i]
        }

        // Process the description
        while ((line = lines[++i]).startsWith('///')) {
          line = line.replace('///', '').trim()
71

72 73 74
          // If the line has any contents, insert it into the desc.
          // Otherwise, consider it a linebreak.
          currentDoc.desc += line.length > 0 ? `${line} ` : '\n'
75
        }
76 77 78 79 80 81

        // Set the line number of the test
        currentDoc.lineNo = i + 1

        // Add the doc to the contract
        contract.docs.push(currentDoc)
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
      }
    }

    // Add the contract to the array of docs
    docs.push(contract)
  }

  for (const contract of docs) {
    const fileName = path.join(BASE_DOCS_DIR, `${contract.name}.md`)
    const alreadyWritten = writtenFiles.includes(fileName)

    // If the file has already been written, append the extra docs to the end.
    // Otherwise, write the file from scratch.
    fs.writeFileSync(
      fileName,
      alreadyWritten
        ? `${fs.readFileSync(fileName)}\n${renderContractDoc(contract, false)}`
        : renderContractDoc(contract, true)
    )

    // If the file was just written for the first time, add it to the list of written files.
    if (!alreadyWritten) {
      writtenFiles.push(fileName)
    }
  }

  console.log(
Mark Tyneway's avatar
Mark Tyneway committed
109 110
    `Generated invariant test documentation for:\n - ${
      docs.length
111 112 113 114 115 116 117
    } contracts\n - ${docs.reduce(
      (acc: number, contract: Contract) => acc + contract.docs.length,
      0
    )} invariant tests\nsuccessfully!`
  )
}

118
//  Generate a table of contents for all invariant docs and place it in the README.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
const tocGen = (): void => {
  const autoTOCPrefix = '<!-- START autoTOC -->\n'
  const autoTOCPostfix = '<!-- END autoTOC -->\n'

  // Grab the name of all markdown files in `BASE_DOCS_DIR` except for `README.md`.
  const files = fs
    .readdirSync(BASE_DOCS_DIR)
    .filter((fileName: string) => fileName !== 'README.md')

  // Generate a table of contents section.
  const tocList = files
    .map(
      (fileName: string) => `- [${fileName.replace('.md', '')}](./${fileName})`
    )
    .join('\n')
  const toc = `${autoTOCPrefix}\n## Table of Contents\n${tocList}\n${autoTOCPostfix}`

  // Write the table of contents to the README.
  const readmeContents = fs
    .readFileSync(path.join(BASE_DOCS_DIR, 'README.md'))
    .toString()
  const above = readmeContents.split(autoTOCPrefix)[0]
  const below = readmeContents.split(autoTOCPostfix)[1]
  fs.writeFileSync(
    path.join(BASE_DOCS_DIR, 'README.md'),
    `${above}${toc}${below}`
  )
}

148
// Render a `Contract` object into valid markdown.
149 150 151 152 153
const renderContractDoc = (contract: Contract, header: boolean): string => {
  const _header = header ? `# \`${contract.name}\` Invariants\n` : ''
  const docs = contract.docs
    .map((doc: InvariantDoc) => {
      const line = `${contract.fileName}#L${doc.lineNo}`
Maurelian's avatar
Maurelian committed
154
      return `## ${doc.header}\n**Test:** [\`${line}\`](${BASE_INVARIANT_GH_URL}${line})\n\n${doc.desc}`
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170
    })
    .join('\n\n')
  return `${_header}\n${docs}`
}

// Generate the docs

// Forge
console.log('Generating docs for forge invariants...')
docGen(BASE_INVARIANTS_DIR)

// New line
console.log()

// Generate an updated table of contents
tocGen()