invariant-doc-gen.ts 5.21 KB
Newer Older
1 2 3
import fs from 'fs'
import path from 'path'

Mark Tyneway's avatar
Mark Tyneway committed
4
const BASE_INVARIANTS_DIR = path.join(__dirname, '..', 'test', 'invariants')
5
const BASE_DOCS_DIR = path.join(__dirname, '..', 'invariant-docs')
6
const BASE_INVARIANT_GH_URL = '../test/invariants/'
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
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 = []

25
// Lazy-parses all test files in the `test/invariants` directory
26
// to generate documentation on all invariant tests.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
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
42 43
    const name = fileName.replace('.t.sol', '')
    const contract: Contract = { name, fileName, docs: [] }
44 45 46 47 48 49 50

    let currentDoc: InvariantDoc

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

51 52 53 54 55 56 57 58
      // 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: '',
        }
59

60
        // If the header is multi-line, continue appending to the `currentDoc`'s header.
61
        line = lines[++i]
62 63 64 65 66 67 68 69
        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()
70

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

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

        // Add the doc to the contract
        contract.docs.push(currentDoc)
81 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
      }
    }

    // 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
108 109
    `Generated invariant test documentation for:\n - ${
      docs.length
110 111 112 113 114 115 116
    } contracts\n - ${docs.reduce(
      (acc: number, contract: Contract) => acc + contract.docs.length,
      0
    )} invariant tests\nsuccessfully!`
  )
}

117
//  Generate a table of contents for all invariant docs and place it in the README.
118 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
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}`
  )
}

147
// Render a `Contract` object into valid markdown.
148 149 150 151 152
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
153
      return `## ${doc.header}\n**Test:** [\`${line}\`](${BASE_INVARIANT_GH_URL}${line})\n\n${doc.desc}`
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
    })
    .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()