@rwillians/doctests 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/cjs/compiler.js +37 -0
- package/dist/cjs/index.js +83 -0
- package/dist/cjs/parser.js +124 -0
- package/dist/esm/compiler.js +33 -0
- package/dist/esm/index.js +79 -0
- package/dist/esm/parser.js +120 -0
- package/dist/types/compiler.d.ts +8 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/parser.d.ts +30 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rafael Willians
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @rwillians/doctests
|
|
2
|
+
|
|
3
|
+
The power of doctests now available in JavaScript and TypeScript!
|
|
4
|
+
|
|
5
|
+
## Examples
|
|
6
|
+
|
|
7
|
+
**src/utils.ts**
|
|
8
|
+
```ts
|
|
9
|
+
/**
|
|
10
|
+
* @example Returns a number of the fibonacci sequence by its given
|
|
11
|
+
* position.
|
|
12
|
+
* ```
|
|
13
|
+
* fib(3) //= 2
|
|
14
|
+
* fib(4) //= 3
|
|
15
|
+
* fib(5) //= 5
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example The value of a position in the fibonacci sequence is equal
|
|
19
|
+
* to the sum of the two preceding numbers.
|
|
20
|
+
* ```
|
|
21
|
+
* fib(5) //= fib(3) + fib(4)
|
|
22
|
+
* fib(5) === fib(3) + fib(4) //= true
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example The position is zero-based.
|
|
26
|
+
*
|
|
27
|
+
* Zero-based indexing means that the first element of a sequence is
|
|
28
|
+
* at position 0, the second element is at position 1, and so on. This
|
|
29
|
+
* is a common convention in programming and mathematics.
|
|
30
|
+
*
|
|
31
|
+
* ```
|
|
32
|
+
* fib(0); //= 0
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* As you can see, the first number of the fibonacci sequence is 0,
|
|
36
|
+
* which is at position 0.
|
|
37
|
+
*
|
|
38
|
+
* @example The first and second numbers of the sequence are equal to
|
|
39
|
+
* one.
|
|
40
|
+
* ```ts
|
|
41
|
+
* const a = fib(1) //= 1
|
|
42
|
+
* const b = fib(2) //= 1
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example The given position cannot be less than zero.
|
|
46
|
+
*
|
|
47
|
+
* ```js
|
|
48
|
+
* fib(-1) //^ **Error**
|
|
49
|
+
* fib(-2) //^ **Error** "position cannot be less than 0, got -2"
|
|
50
|
+
* fib(-3) //^ **Error** 'position cannot be less than 0, got -3'
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export const fib = (position: number): number => {
|
|
54
|
+
if (position < 0) throw new Error(`position cannot be less than 0, got ${position}`);
|
|
55
|
+
if (position === 0) return 0;
|
|
56
|
+
if (position === 1) return 1;
|
|
57
|
+
if (position === 2) return 1;
|
|
58
|
+
|
|
59
|
+
return fib(position - 2) + fib(position - 1);
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**test/doctests.spec.ts**
|
|
64
|
+
```ts
|
|
65
|
+
import { doctest } from '@rwillians/doctests';
|
|
66
|
+
|
|
67
|
+
doctest({
|
|
68
|
+
include: ['src/**/*.ts'],
|
|
69
|
+
exclude: ['src/**/*.d.ts', 'src/**/*.{test,spec}.ts'],
|
|
70
|
+
});
|
|
71
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compiles a parsed example into the body of an async function. The
|
|
4
|
+
* runner provides `expect` (from the test framework) and `__errorName`
|
|
5
|
+
* (error-name matcher) as parameters, and destructures the target
|
|
6
|
+
* module's exports above this body — so steps can call them directly.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.compile = void 0;
|
|
10
|
+
const compileStep = (step) => {
|
|
11
|
+
switch (step.kind) {
|
|
12
|
+
case 'exec':
|
|
13
|
+
return step.code;
|
|
14
|
+
case 'equal':
|
|
15
|
+
return step.binding === null
|
|
16
|
+
? `expect(${step.expression}).toEqual(${step.expected});`
|
|
17
|
+
: `${step.expression};\nexpect(${step.binding}).toEqual(${step.expected});`;
|
|
18
|
+
case 'throws': {
|
|
19
|
+
const error = JSON.stringify(step.error);
|
|
20
|
+
const lines = [
|
|
21
|
+
'{',
|
|
22
|
+
' let __thrown = undefined;',
|
|
23
|
+
' let __threw = false;',
|
|
24
|
+
` try { ${step.expression}; } catch (error) { __thrown = error; __threw = true; }`,
|
|
25
|
+
` if (!__threw) throw new Error('expected \`' + ${JSON.stringify(step.expression)} + '\` to throw ' + ${error} + ', but nothing was thrown');`,
|
|
26
|
+
` expect(__errorName(__thrown, ${error})).toBe(${error});`,
|
|
27
|
+
];
|
|
28
|
+
if (step.message !== null) {
|
|
29
|
+
lines.push(` expect(String(__thrown.message)).toContain(${JSON.stringify(step.message)});`);
|
|
30
|
+
}
|
|
31
|
+
lines.push('}');
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const compile = (example) => example.steps.map(compileStep).join('\n');
|
|
37
|
+
exports.compile = compile;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.doctest = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const bun_test_1 = require("bun:test");
|
|
7
|
+
const compiler_1 = require("./compiler");
|
|
8
|
+
const parser_1 = require("./parser");
|
|
9
|
+
const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor;
|
|
10
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* Matches a thrown value against an expected error name. Returns the
|
|
13
|
+
* expected name on a match (by `error.name` or constructor name), or
|
|
14
|
+
* the closest description of what was actually thrown — so a mismatch
|
|
15
|
+
* reads as `expect(received).toBe(expected)` with both names visible.
|
|
16
|
+
*/
|
|
17
|
+
const errorName = (error, expected) => {
|
|
18
|
+
if (error === null || typeof error !== 'object')
|
|
19
|
+
return String(error);
|
|
20
|
+
const names = [error.name, error.constructor?.name];
|
|
21
|
+
if (names.includes(expected))
|
|
22
|
+
return expected;
|
|
23
|
+
return names.find((name) => typeof name === 'string') ?? String(error);
|
|
24
|
+
};
|
|
25
|
+
let transpiler;
|
|
26
|
+
/** Strips TypeScript syntax so ```ts blocks run through `new Function`. */
|
|
27
|
+
const transpile = (code) => {
|
|
28
|
+
if (typeof Bun === 'undefined')
|
|
29
|
+
return code;
|
|
30
|
+
transpiler ??= new Bun.Transpiler({ loader: 'ts', deadCodeElimination: false });
|
|
31
|
+
return transpiler.transformSync(code);
|
|
32
|
+
};
|
|
33
|
+
const run = async (exports, example) => {
|
|
34
|
+
const names = Object.keys(exports).filter((name) => IDENTIFIER.test(name));
|
|
35
|
+
const header = names.length > 0 ? `const { ${names.join(', ')} } = __exports__;` : '';
|
|
36
|
+
const body = transpile((0, compiler_1.compile)(example));
|
|
37
|
+
const compiled = new AsyncFunction('__exports__', 'expect', '__errorName', `"use strict";\n${header}\n${body}`);
|
|
38
|
+
await compiled(exports, bun_test_1.expect, errorName);
|
|
39
|
+
};
|
|
40
|
+
const title = (description) => description.replace(/\.$/, '') || 'doctest';
|
|
41
|
+
const register = (root, path) => {
|
|
42
|
+
const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, path), 'utf8');
|
|
43
|
+
const docs = (0, parser_1.parse)(source).filter((doc) => doc.exported);
|
|
44
|
+
if (docs.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
(0, bun_test_1.describe)(path, () => {
|
|
47
|
+
for (const doc of docs) {
|
|
48
|
+
(0, bun_test_1.describe)(doc.subject, () => {
|
|
49
|
+
for (const example of doc.examples) {
|
|
50
|
+
(0, bun_test_1.it)(title(example.description), async () => {
|
|
51
|
+
const exports = (await Promise.resolve(`${(0, node_path_1.resolve)(root, path)}`).then(s => require(s)));
|
|
52
|
+
await run(exports, example);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Registers every doctest found in the files matched by the `include`
|
|
61
|
+
* globs (relative to the current working directory, minus any matched
|
|
62
|
+
* by `exclude`) as regular `bun:test` cases:
|
|
63
|
+
* `describe(file) > describe(symbol) > it(example)`.
|
|
64
|
+
* Doctests on symbols that are not exported are skipped. A bare array
|
|
65
|
+
* of globs is accepted as shorthand for `{ include }`.
|
|
66
|
+
*/
|
|
67
|
+
const doctest = (options) => {
|
|
68
|
+
const { include, exclude = [] } = Array.isArray(options) ? { include: options } : options;
|
|
69
|
+
const root = process.cwd();
|
|
70
|
+
const paths = new Set();
|
|
71
|
+
for (const pattern of include) {
|
|
72
|
+
for (const path of new Bun.Glob(pattern).scanSync({ cwd: root })) {
|
|
73
|
+
paths.add(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const excluded = exclude.map((pattern) => new Bun.Glob(pattern));
|
|
77
|
+
for (const path of [...paths].sort()) {
|
|
78
|
+
if (excluded.some((glob) => glob.match(path)))
|
|
79
|
+
continue;
|
|
80
|
+
register(root, path);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
exports.doctest = doctest;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Dependency-free doctest parser. Scans JSDoc blocks for `@example`
|
|
4
|
+
* tags and turns their fenced code blocks into executable steps. Text
|
|
5
|
+
* scanning only — no TypeScript compiler API — so it works the same
|
|
6
|
+
* for `.js` and `.ts` sources.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.parse = void 0;
|
|
10
|
+
const COMMENT = /\/\*\*([\s\S]*?)\*\//g;
|
|
11
|
+
const STAR_PREFIX = /^\s*\* ?/;
|
|
12
|
+
const TAG = /^@(\w+)\s*/;
|
|
13
|
+
const FENCE = /^```/;
|
|
14
|
+
const DECLARATION = /^(export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:const|let|var|function\*?|class|enum)\s+([A-Za-z_$][\w$]*)/;
|
|
15
|
+
const BINDING = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
|
|
16
|
+
const THROWS = /^\*\*([^*]+)\*\*\s*(?:"([^"]*)"|'([^']*)')?$/;
|
|
17
|
+
const cleanLine = (line) => line.replace(STAR_PREFIX, '').trimEnd();
|
|
18
|
+
/**
|
|
19
|
+
* The symbol a JSDoc block documents: the first declaration after the
|
|
20
|
+
* comment (blank lines and `//` comments in between are skipped).
|
|
21
|
+
*/
|
|
22
|
+
const declarationAfter = (source, index) => {
|
|
23
|
+
for (const line of source.slice(index).split('\n')) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (trimmed === '' || trimmed.startsWith('//'))
|
|
26
|
+
continue;
|
|
27
|
+
const match = DECLARATION.exec(trimmed);
|
|
28
|
+
if (!match)
|
|
29
|
+
return null;
|
|
30
|
+
return { subject: match[2], exported: match[1] !== undefined };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Splits a cleaned JSDoc body into `@example` sections. A section runs
|
|
36
|
+
* from its `@example` line to the next `@tag` (or the end of the
|
|
37
|
+
* block). Tag-looking lines inside code fences are left alone.
|
|
38
|
+
*/
|
|
39
|
+
const splitExamples = (lines) => {
|
|
40
|
+
const sections = [];
|
|
41
|
+
let current = null;
|
|
42
|
+
let fenced = false;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (FENCE.test(trimmed)) {
|
|
46
|
+
fenced = !fenced;
|
|
47
|
+
current?.push(line);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const tag = fenced ? null : TAG.exec(trimmed);
|
|
51
|
+
if (tag) {
|
|
52
|
+
current = tag[1] === 'example' ? [trimmed.replace(TAG, '')] : null;
|
|
53
|
+
if (current)
|
|
54
|
+
sections.push(current);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
current?.push(line);
|
|
58
|
+
}
|
|
59
|
+
return sections;
|
|
60
|
+
};
|
|
61
|
+
const classify = (line) => {
|
|
62
|
+
const markers = [line.indexOf('//='), line.indexOf('//^')].filter((at) => at >= 0);
|
|
63
|
+
const at = markers.length > 0 ? Math.min(...markers) : -1;
|
|
64
|
+
if (at < 0)
|
|
65
|
+
return { kind: 'exec', code: line };
|
|
66
|
+
const expression = line.slice(0, at).trim().replace(/;$/, '');
|
|
67
|
+
const rest = line.slice(at + 3).trim();
|
|
68
|
+
if (line.startsWith('//=', at)) {
|
|
69
|
+
if (rest === '')
|
|
70
|
+
throw new Error(`malformed doctest assertion, expected a value after \`//=\`: ${line}`);
|
|
71
|
+
const binding = BINDING.exec(expression);
|
|
72
|
+
return { kind: 'equal', binding: binding?.[1] ?? null, expression, expected: rest };
|
|
73
|
+
}
|
|
74
|
+
const throws = THROWS.exec(rest);
|
|
75
|
+
if (!throws)
|
|
76
|
+
throw new Error(`malformed doctest assertion, expected \`//^ **ErrorType** "optional message"\`: ${line}`);
|
|
77
|
+
return { kind: 'throws', expression, error: throws[1].trim(), message: throws[2] ?? throws[3] ?? null };
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Parses one `@example` section. The description is the text from the
|
|
81
|
+
* tag up to the first blank line or code fence; prose paragraphs after
|
|
82
|
+
* that are ignored; every fenced code block contributes steps.
|
|
83
|
+
*/
|
|
84
|
+
const parseExample = (lines) => {
|
|
85
|
+
const description = [];
|
|
86
|
+
const steps = [];
|
|
87
|
+
let fenced = false;
|
|
88
|
+
let describing = true;
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (FENCE.test(trimmed)) {
|
|
92
|
+
fenced = !fenced;
|
|
93
|
+
describing = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (fenced) {
|
|
97
|
+
if (trimmed !== '')
|
|
98
|
+
steps.push(classify(trimmed));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (describing && trimmed === '') {
|
|
102
|
+
describing = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (describing)
|
|
106
|
+
description.push(trimmed);
|
|
107
|
+
}
|
|
108
|
+
return { description: description.join(' ').replace(/\s+/g, ' ').trim(), steps };
|
|
109
|
+
};
|
|
110
|
+
const parse = (source) => {
|
|
111
|
+
const docs = [];
|
|
112
|
+
for (const match of source.matchAll(COMMENT)) {
|
|
113
|
+
const declaration = declarationAfter(source, match.index + match[0].length);
|
|
114
|
+
if (!declaration)
|
|
115
|
+
continue;
|
|
116
|
+
const lines = match[1].split('\n').map(cleanLine);
|
|
117
|
+
const examples = splitExamples(lines).map(parseExample).filter((example) => example.steps.length > 0);
|
|
118
|
+
if (examples.length === 0)
|
|
119
|
+
continue;
|
|
120
|
+
docs.push({ ...declaration, examples });
|
|
121
|
+
}
|
|
122
|
+
return docs;
|
|
123
|
+
};
|
|
124
|
+
exports.parse = parse;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles a parsed example into the body of an async function. The
|
|
3
|
+
* runner provides `expect` (from the test framework) and `__errorName`
|
|
4
|
+
* (error-name matcher) as parameters, and destructures the target
|
|
5
|
+
* module's exports above this body — so steps can call them directly.
|
|
6
|
+
*/
|
|
7
|
+
const compileStep = (step) => {
|
|
8
|
+
switch (step.kind) {
|
|
9
|
+
case 'exec':
|
|
10
|
+
return step.code;
|
|
11
|
+
case 'equal':
|
|
12
|
+
return step.binding === null
|
|
13
|
+
? `expect(${step.expression}).toEqual(${step.expected});`
|
|
14
|
+
: `${step.expression};\nexpect(${step.binding}).toEqual(${step.expected});`;
|
|
15
|
+
case 'throws': {
|
|
16
|
+
const error = JSON.stringify(step.error);
|
|
17
|
+
const lines = [
|
|
18
|
+
'{',
|
|
19
|
+
' let __thrown = undefined;',
|
|
20
|
+
' let __threw = false;',
|
|
21
|
+
` try { ${step.expression}; } catch (error) { __thrown = error; __threw = true; }`,
|
|
22
|
+
` if (!__threw) throw new Error('expected \`' + ${JSON.stringify(step.expression)} + '\` to throw ' + ${error} + ', but nothing was thrown');`,
|
|
23
|
+
` expect(__errorName(__thrown, ${error})).toBe(${error});`,
|
|
24
|
+
];
|
|
25
|
+
if (step.message !== null) {
|
|
26
|
+
lines.push(` expect(String(__thrown.message)).toContain(${JSON.stringify(step.message)});`);
|
|
27
|
+
}
|
|
28
|
+
lines.push('}');
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
export const compile = (example) => example.steps.map(compileStep).join('\n');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'bun:test';
|
|
4
|
+
import { compile } from './compiler';
|
|
5
|
+
import { parse } from './parser';
|
|
6
|
+
const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor;
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
8
|
+
/**
|
|
9
|
+
* Matches a thrown value against an expected error name. Returns the
|
|
10
|
+
* expected name on a match (by `error.name` or constructor name), or
|
|
11
|
+
* the closest description of what was actually thrown — so a mismatch
|
|
12
|
+
* reads as `expect(received).toBe(expected)` with both names visible.
|
|
13
|
+
*/
|
|
14
|
+
const errorName = (error, expected) => {
|
|
15
|
+
if (error === null || typeof error !== 'object')
|
|
16
|
+
return String(error);
|
|
17
|
+
const names = [error.name, error.constructor?.name];
|
|
18
|
+
if (names.includes(expected))
|
|
19
|
+
return expected;
|
|
20
|
+
return names.find((name) => typeof name === 'string') ?? String(error);
|
|
21
|
+
};
|
|
22
|
+
let transpiler;
|
|
23
|
+
/** Strips TypeScript syntax so ```ts blocks run through `new Function`. */
|
|
24
|
+
const transpile = (code) => {
|
|
25
|
+
if (typeof Bun === 'undefined')
|
|
26
|
+
return code;
|
|
27
|
+
transpiler ??= new Bun.Transpiler({ loader: 'ts', deadCodeElimination: false });
|
|
28
|
+
return transpiler.transformSync(code);
|
|
29
|
+
};
|
|
30
|
+
const run = async (exports, example) => {
|
|
31
|
+
const names = Object.keys(exports).filter((name) => IDENTIFIER.test(name));
|
|
32
|
+
const header = names.length > 0 ? `const { ${names.join(', ')} } = __exports__;` : '';
|
|
33
|
+
const body = transpile(compile(example));
|
|
34
|
+
const compiled = new AsyncFunction('__exports__', 'expect', '__errorName', `"use strict";\n${header}\n${body}`);
|
|
35
|
+
await compiled(exports, expect, errorName);
|
|
36
|
+
};
|
|
37
|
+
const title = (description) => description.replace(/\.$/, '') || 'doctest';
|
|
38
|
+
const register = (root, path) => {
|
|
39
|
+
const source = readFileSync(resolve(root, path), 'utf8');
|
|
40
|
+
const docs = parse(source).filter((doc) => doc.exported);
|
|
41
|
+
if (docs.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
describe(path, () => {
|
|
44
|
+
for (const doc of docs) {
|
|
45
|
+
describe(doc.subject, () => {
|
|
46
|
+
for (const example of doc.examples) {
|
|
47
|
+
it(title(example.description), async () => {
|
|
48
|
+
const exports = (await import(resolve(root, path)));
|
|
49
|
+
await run(exports, example);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Registers every doctest found in the files matched by the `include`
|
|
58
|
+
* globs (relative to the current working directory, minus any matched
|
|
59
|
+
* by `exclude`) as regular `bun:test` cases:
|
|
60
|
+
* `describe(file) > describe(symbol) > it(example)`.
|
|
61
|
+
* Doctests on symbols that are not exported are skipped. A bare array
|
|
62
|
+
* of globs is accepted as shorthand for `{ include }`.
|
|
63
|
+
*/
|
|
64
|
+
export const doctest = (options) => {
|
|
65
|
+
const { include, exclude = [] } = Array.isArray(options) ? { include: options } : options;
|
|
66
|
+
const root = process.cwd();
|
|
67
|
+
const paths = new Set();
|
|
68
|
+
for (const pattern of include) {
|
|
69
|
+
for (const path of new Bun.Glob(pattern).scanSync({ cwd: root })) {
|
|
70
|
+
paths.add(path);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const excluded = exclude.map((pattern) => new Bun.Glob(pattern));
|
|
74
|
+
for (const path of [...paths].sort()) {
|
|
75
|
+
if (excluded.some((glob) => glob.match(path)))
|
|
76
|
+
continue;
|
|
77
|
+
register(root, path);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free doctest parser. Scans JSDoc blocks for `@example`
|
|
3
|
+
* tags and turns their fenced code blocks into executable steps. Text
|
|
4
|
+
* scanning only — no TypeScript compiler API — so it works the same
|
|
5
|
+
* for `.js` and `.ts` sources.
|
|
6
|
+
*/
|
|
7
|
+
const COMMENT = /\/\*\*([\s\S]*?)\*\//g;
|
|
8
|
+
const STAR_PREFIX = /^\s*\* ?/;
|
|
9
|
+
const TAG = /^@(\w+)\s*/;
|
|
10
|
+
const FENCE = /^```/;
|
|
11
|
+
const DECLARATION = /^(export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:const|let|var|function\*?|class|enum)\s+([A-Za-z_$][\w$]*)/;
|
|
12
|
+
const BINDING = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
|
|
13
|
+
const THROWS = /^\*\*([^*]+)\*\*\s*(?:"([^"]*)"|'([^']*)')?$/;
|
|
14
|
+
const cleanLine = (line) => line.replace(STAR_PREFIX, '').trimEnd();
|
|
15
|
+
/**
|
|
16
|
+
* The symbol a JSDoc block documents: the first declaration after the
|
|
17
|
+
* comment (blank lines and `//` comments in between are skipped).
|
|
18
|
+
*/
|
|
19
|
+
const declarationAfter = (source, index) => {
|
|
20
|
+
for (const line of source.slice(index).split('\n')) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (trimmed === '' || trimmed.startsWith('//'))
|
|
23
|
+
continue;
|
|
24
|
+
const match = DECLARATION.exec(trimmed);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
return { subject: match[2], exported: match[1] !== undefined };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Splits a cleaned JSDoc body into `@example` sections. A section runs
|
|
33
|
+
* from its `@example` line to the next `@tag` (or the end of the
|
|
34
|
+
* block). Tag-looking lines inside code fences are left alone.
|
|
35
|
+
*/
|
|
36
|
+
const splitExamples = (lines) => {
|
|
37
|
+
const sections = [];
|
|
38
|
+
let current = null;
|
|
39
|
+
let fenced = false;
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (FENCE.test(trimmed)) {
|
|
43
|
+
fenced = !fenced;
|
|
44
|
+
current?.push(line);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const tag = fenced ? null : TAG.exec(trimmed);
|
|
48
|
+
if (tag) {
|
|
49
|
+
current = tag[1] === 'example' ? [trimmed.replace(TAG, '')] : null;
|
|
50
|
+
if (current)
|
|
51
|
+
sections.push(current);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
current?.push(line);
|
|
55
|
+
}
|
|
56
|
+
return sections;
|
|
57
|
+
};
|
|
58
|
+
const classify = (line) => {
|
|
59
|
+
const markers = [line.indexOf('//='), line.indexOf('//^')].filter((at) => at >= 0);
|
|
60
|
+
const at = markers.length > 0 ? Math.min(...markers) : -1;
|
|
61
|
+
if (at < 0)
|
|
62
|
+
return { kind: 'exec', code: line };
|
|
63
|
+
const expression = line.slice(0, at).trim().replace(/;$/, '');
|
|
64
|
+
const rest = line.slice(at + 3).trim();
|
|
65
|
+
if (line.startsWith('//=', at)) {
|
|
66
|
+
if (rest === '')
|
|
67
|
+
throw new Error(`malformed doctest assertion, expected a value after \`//=\`: ${line}`);
|
|
68
|
+
const binding = BINDING.exec(expression);
|
|
69
|
+
return { kind: 'equal', binding: binding?.[1] ?? null, expression, expected: rest };
|
|
70
|
+
}
|
|
71
|
+
const throws = THROWS.exec(rest);
|
|
72
|
+
if (!throws)
|
|
73
|
+
throw new Error(`malformed doctest assertion, expected \`//^ **ErrorType** "optional message"\`: ${line}`);
|
|
74
|
+
return { kind: 'throws', expression, error: throws[1].trim(), message: throws[2] ?? throws[3] ?? null };
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Parses one `@example` section. The description is the text from the
|
|
78
|
+
* tag up to the first blank line or code fence; prose paragraphs after
|
|
79
|
+
* that are ignored; every fenced code block contributes steps.
|
|
80
|
+
*/
|
|
81
|
+
const parseExample = (lines) => {
|
|
82
|
+
const description = [];
|
|
83
|
+
const steps = [];
|
|
84
|
+
let fenced = false;
|
|
85
|
+
let describing = true;
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (FENCE.test(trimmed)) {
|
|
89
|
+
fenced = !fenced;
|
|
90
|
+
describing = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (fenced) {
|
|
94
|
+
if (trimmed !== '')
|
|
95
|
+
steps.push(classify(trimmed));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (describing && trimmed === '') {
|
|
99
|
+
describing = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (describing)
|
|
103
|
+
description.push(trimmed);
|
|
104
|
+
}
|
|
105
|
+
return { description: description.join(' ').replace(/\s+/g, ' ').trim(), steps };
|
|
106
|
+
};
|
|
107
|
+
export const parse = (source) => {
|
|
108
|
+
const docs = [];
|
|
109
|
+
for (const match of source.matchAll(COMMENT)) {
|
|
110
|
+
const declaration = declarationAfter(source, match.index + match[0].length);
|
|
111
|
+
if (!declaration)
|
|
112
|
+
continue;
|
|
113
|
+
const lines = match[1].split('\n').map(cleanLine);
|
|
114
|
+
const examples = splitExamples(lines).map(parseExample).filter((example) => example.steps.length > 0);
|
|
115
|
+
if (examples.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
docs.push({ ...declaration, examples });
|
|
118
|
+
}
|
|
119
|
+
return docs;
|
|
120
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles a parsed example into the body of an async function. The
|
|
3
|
+
* runner provides `expect` (from the test framework) and `__errorName`
|
|
4
|
+
* (error-name matcher) as parameters, and destructures the target
|
|
5
|
+
* module's exports above this body — so steps can call them directly.
|
|
6
|
+
*/
|
|
7
|
+
import type { Example } from './parser';
|
|
8
|
+
export declare const compile: (example: Example) => string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type DoctestOptions = {
|
|
2
|
+
include: string[];
|
|
3
|
+
exclude?: string[];
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Registers every doctest found in the files matched by the `include`
|
|
7
|
+
* globs (relative to the current working directory, minus any matched
|
|
8
|
+
* by `exclude`) as regular `bun:test` cases:
|
|
9
|
+
* `describe(file) > describe(symbol) > it(example)`.
|
|
10
|
+
* Doctests on symbols that are not exported are skipped. A bare array
|
|
11
|
+
* of globs is accepted as shorthand for `{ include }`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const doctest: (options: string[] | DoctestOptions) => void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free doctest parser. Scans JSDoc blocks for `@example`
|
|
3
|
+
* tags and turns their fenced code blocks into executable steps. Text
|
|
4
|
+
* scanning only — no TypeScript compiler API — so it works the same
|
|
5
|
+
* for `.js` and `.ts` sources.
|
|
6
|
+
*/
|
|
7
|
+
export type Step = {
|
|
8
|
+
kind: 'exec';
|
|
9
|
+
code: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'equal';
|
|
12
|
+
binding: string | null;
|
|
13
|
+
expression: string;
|
|
14
|
+
expected: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'throws';
|
|
17
|
+
expression: string;
|
|
18
|
+
error: string;
|
|
19
|
+
message: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type Example = {
|
|
22
|
+
description: string;
|
|
23
|
+
steps: Step[];
|
|
24
|
+
};
|
|
25
|
+
export type Doc = {
|
|
26
|
+
subject: string;
|
|
27
|
+
exported: boolean;
|
|
28
|
+
examples: Example[];
|
|
29
|
+
};
|
|
30
|
+
export declare const parse: (source: string) => Doc[];
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rwillians/doctests",
|
|
3
|
+
"description": "The power of doctests now available to JavaScript and TypeScript.",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"publishConfig": { "access": "public" },
|
|
6
|
+
"author": "Rafael Willians <me@rwillians.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git://github.com/rwillians/doctests-js.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"documentation",
|
|
14
|
+
"test",
|
|
15
|
+
"doctest",
|
|
16
|
+
"doctests",
|
|
17
|
+
"bun",
|
|
18
|
+
"jest"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"module": "dist/esm/index.js",
|
|
22
|
+
"main": "dist/cjs/index.js",
|
|
23
|
+
"files": ["dist/", "LICENSE"],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/types/index.d.ts",
|
|
27
|
+
"import": "./dist/esm/index.js",
|
|
28
|
+
"require": "./dist/cjs/index.js",
|
|
29
|
+
"default": "./dist/cjs/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "bun run build:cjs && bun run build:esm",
|
|
34
|
+
"build:cjs": "bun run --bun tsc --project tsconfig-cjs.json",
|
|
35
|
+
"build:esm": "bun run --bun tsc --project tsconfig-esm.json"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "^1.3.14"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|