From a93318a0b3cccd1eaee216eb98187a84b857842d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dan=20Ros=C3=A9n?= <dan.rosen@gu.se>
Date: Thu, 2 Nov 2017 15:46:19 +0100
Subject: [PATCH] Support doctests for typescript

---
 LICENSE       |  21 +++++
 package.json  |  34 +++++++
 src/main.ts   | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++
 tsconfig.json |  10 +++
 4 files changed, 308 insertions(+)
 create mode 100644 LICENSE
 create mode 100644 package.json
 create mode 100644 src/main.ts
 create mode 100644 tsconfig.json

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fdc387a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) Dan Rosén
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..57771d0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "typescript-doctest",
+  "version": "0.1.0",
+  "description": "doctest support for typescript",
+  "main": "src/main.ts",
+  "bin": {
+    "typescript-doctest": "src/main.js"
+  },
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/danr/typescript-doctest.git"
+  },
+  "keywords": [
+    "doctest",
+    "typescript",
+    "testing"
+  ],
+  "author": "Dan Rosén",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/danr/typescript-doctest/issues"
+  },
+  "homepage": "https://github.com/danr/typescript-doctest#readme",
+  "dependencies": {
+    "fs": "0.0.1-security",
+    "typescript": "^2.6.1"
+  },
+  "devDependencies": {
+    "@types/node": "^8.0.47"
+  }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..6b7ac05
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,243 @@
+#!/usr/bin/env node
+import * as ts from 'typescript'
+import * as fs from "fs"
+
+const is_doctest = (s: string) => s.match(/\/\/[ \t]*=>/) != null
+
+function replicate<A>(i: number, x: A): A[] {
+  const out = [] as A[]
+  while (i-- > 0) out.push(x);
+  return out
+}
+
+type Top = {filename: string, defs: Defs}[]
+
+type Defs = Def[]
+
+interface Def {
+  name: string,
+  type?: string,
+  doc: string,
+  exported: boolean,
+  typedef: boolean,
+  flags: string[],
+  children: Defs,
+  kind: string,
+}
+
+function walk(defs: Defs, f: (def: Def, d: number) => void, d0: number = 0): void {
+  defs.forEach(d => (f(d, d0), walk(d.children, f, d0 + 1)))
+}
+
+function flatMap<A, B>(xs: A[], f: (a: A) => B[]): B[] {
+  return ([] as B[]).concat(...xs.map(f))
+}
+
+function flatten<A>(xss: A[][]): A[] {
+  return ([] as A[]).concat(...xss)
+}
+
+/** Generate documentation for all classes in a set of .ts files */
+function generateDocumentation(fileNames: string[], options: ts.CompilerOptions): Top {
+  const program = ts.createProgram(fileNames, options)
+  const checker = program.getTypeChecker()
+  const printer = ts.createPrinter()
+  return program.getSourceFiles()
+    .filter(file => -1 != fileNames.indexOf(file.fileName))
+    .map(file => ({
+      filename: file.fileName,
+      defs: flatten(file.statements.map(visits))
+    }))
+
+  function isNodeExported(node: ts.Node): boolean {
+     return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0
+  }
+
+  function visits(node: ts.Node): Defs {
+    if (ts.isVariableStatement(node)) {
+      // top-level const
+      return flatten(node.declarationList.declarations.map(visits))
+    // } else if (ts.isExportAssignment(node)) {
+    //   return { exAss: ts.forEachChild(node, visit) }
+    // } else if (ts.isExportDeclaration(node)) {
+    //   return { exDecl: ts.forEachChild(node, visit) }
+    } if (
+      (  ts.isInterfaceDeclaration(node)
+      || ts.isClassDeclaration(node)
+      || ts.isFunctionDeclaration(node)
+      || ts.isMethodDeclaration(node)
+      || ts.isPropertyDeclaration(node)  // fields in classes
+      || ts.isTypeElement(node)          // fields in interface records
+      || ts.isTypeAliasDeclaration(node) // type A = ...
+      || ts.isVariableDeclaration(node)  // top-level const
+      || ts.isModuleDeclaration(node)
+    ) && node.name) {
+      const symbol = checker.getSymbolAtLocation(node.name);
+      const doc = (((node as any).jsDoc || [])[0] || {}).comment || ''
+      if (symbol) {
+        const out: Def = {
+          name: symbol.name,
+          doc,
+          exported: isNodeExported(node),
+          kind: ts.SyntaxKind[node.kind],
+          flags: [
+            ts.ModifierFlags.None,
+            ts.ModifierFlags.Export,
+            ts.ModifierFlags.Ambient,
+            ts.ModifierFlags.Public,
+            ts.ModifierFlags.Private,
+            ts.ModifierFlags.Protected,
+            ts.ModifierFlags.Static,
+            ts.ModifierFlags.Readonly,
+            ts.ModifierFlags.Abstract,
+            ts.ModifierFlags.Async,
+            ts.ModifierFlags.Default,
+            ts.ModifierFlags.Const
+          ].map(flag => ts.ModifierFlags[symbol.flags & flag])
+           .filter(flag => flag != 'None'),
+          typedef: ts.isInterfaceDeclaration(node)
+                 || ts.isClassDeclaration(node)
+                 || ts.isTypeAliasDeclaration(node),
+          type:
+            (symbol.valueDeclaration && !ts.isClassDeclaration(node))
+            ? checker.typeToString(checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration), node, 65535)
+            : undefined,
+          children: []
+        }
+        if ( ts.isInterfaceDeclaration(node) ) {
+          out.children = flatten(node.members.map(visits))
+        }
+        if ( ts.isClassDeclaration(node) ) {
+          out.children = flatten(node.members.map(visits))
+        }
+        if ( ts.isModuleDeclaration(node) && node.body ) {
+          const b = node.body
+          if (b.kind == ts.SyntaxKind.ModuleBlock) {
+            out.children = flatten(b.statements.map(visits))
+          }
+        }
+        return [out]
+      }
+      return []
+    } else if (ts.isModuleBlock(node)) {
+      return flatten(node.getChildren().map(visits))
+    } else {
+      console.error("Ignoring " + ts.SyntaxKind[node.kind])
+      return []
+    }
+  }
+}
+
+const pwoc = ts.createPrinter({removeComments: true})
+
+function script(s: string): string[] {
+  const f = ts.createSourceFile('test.ts', s, ts.ScriptTarget.ES5, true, ts.ScriptKind.TS)
+  const out =
+    f.statements.map(
+      (now, i) => {
+        if (ts.isExpressionStatement(now)) {
+          const next = f.statements[i+1] // zip with next
+          const [a, z] = next ? [next.pos, next.end] : [now.end, f.end]
+          const after = f.text.slice(a, z)
+          const m = after.match(/^\s*\/\/[ \t]*=>([^\n]*)/m)
+          if (m && m[1]) {
+            const lhs = pwoc.printNode(ts.EmitHint.Expression, now.expression, f)
+            const rhs = m[1].trim()
+            return 'assert.deepEqual(' + lhs + ', ' + rhs + ', ' + JSON.stringify(rhs) + ')'
+          }
+        }
+        return pwoc.printNode(ts.EmitHint.Unspecified, now, f)
+      })
+  return out
+}
+
+const headers = ["import * as test from 'tape'"]
+const filenames = [] as string[]
+let fileout = null
+const argv = process.argv
+for (let i = 2; i < argv.length; i++) {
+  const arg = argv[i]
+  if (arg == '-o') {
+    fileout = argv[++i]
+  } else if (arg == '-i') {
+    headers.push(argv[++i])
+  } else {
+    filenames.push(arg)
+  }
+}
+
+const top = generateDocumentation(filenames, {
+   target: ts.ScriptTarget.ES5, module: ts.ModuleKind.CommonJS
+})
+
+
+if (fileout != null) {
+  const out = headers.slice()
+
+  top.forEach(({filename, defs}) => {
+    walk(defs, d => {
+      let tests = 0
+      d.doc.split(/\n\n+/m).map(s => {
+        if (is_doctest(s)) {
+          out.push(
+            'test(' + JSON.stringify(d.name + ' ' + ++tests) + ', assert => {',
+            ...script(s).map(l => '  ' + l),
+            '  assert.end()',
+            '})',
+            ''
+          )
+        }
+      })
+    })
+  })
+
+  fs.writeFileSync(fileout, out.join('\n'));
+}
+
+const readme = [] as string[]
+
+readme.push('## API overview')
+
+function prettyKind(kind: string) {
+  return kind.replace('Declaration', '').toLowerCase()
+}
+
+top.forEach(({defs}) => {
+  walk(defs, (def, i) => {
+    if (def.exported || i > 0) {
+      readme.push(
+        replicate(i, '  ').join('') +
+        '* ' +
+        (def.children.length == 0 ? '' : (prettyKind(def.kind) + ' ')) +
+        def.name)
+    }
+  })
+})
+
+top.forEach(({defs}) =>
+  walk(defs, (def, i) => {
+    if (def.exported || i > 0) {
+      let indent = ''
+      if (def.children.length == 0) {
+        //const method = (def.kind == 'MethodDeclaration') ? 'method ' : ''
+        readme.push('* ' + '**' + def.name + '**: `' + def.type + '`')
+        indent = '  '
+      } else {
+        readme.push('### ' + prettyKind(def.kind) + ' ' + def.name)
+      }
+      def.doc.split(/\n\n+/).forEach(s => {
+        readme.push('')
+        if (is_doctest(s)) {
+          readme.push(indent + '```typescript')
+        }
+        readme.push(indent + s.trim().split('\n').join('\n' + indent))
+        if (is_doctest(s)) {
+          readme.push(indent + '```')
+        }
+      })
+    }
+  })
+)
+
+console.log(readme.join('\n'))
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..33924ca
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "module": "commonjs",
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true,
+    "alwaysStrict": true
+  }
+}