Support doctests for typescript
This commit is contained in:
commit
a93318a0b3
4 changed files with 308 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -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.
|
34
package.json
Normal file
34
package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
243
src/main.ts
Normal file
243
src/main.ts
Normal file
|
@ -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'))
|
||||||
|
|
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "commonjs",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"alwaysStrict": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue