generated from blueplum/typescript-template
feat: basically the whole package
This commit is contained in:
@@ -45,6 +45,14 @@ export default defineConfig([
|
||||
reportUsedIgnorePattern: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unnecessary-condition": [
|
||||
"error",
|
||||
{
|
||||
allowConstantLoopConditions:
|
||||
"only-allowed-literals",
|
||||
},
|
||||
],
|
||||
"no-empty-pattern": "off",
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run --reporter=tree",
|
||||
"build": "tsc -b && esbuild --minify --bundle src/index.ts --outdir=dist --define:import.meta.vitest=undefined",
|
||||
"build": "tsc -b && esbuild --minify --bundle src/index.ts --outdir=dist --define:import.meta.vitest=undefined --platform=node --format=esm --packages=external",
|
||||
"fmt": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
@@ -13,6 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.3.0",
|
||||
@@ -21,5 +22,10 @@
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"arktype": "^2.2.2",
|
||||
"citty": "^0.2.2",
|
||||
"picomatch": "^4.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+60
-9
@@ -7,6 +7,16 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
arktype:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
citty:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
picomatch:
|
||||
specifier: ^4.0.5
|
||||
version: 4.0.5
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
@@ -14,6 +24,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.12.3
|
||||
version: 24.13.2
|
||||
'@types/picomatch':
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
'@typescript-eslint/types':
|
||||
specifier: ^8.61.1
|
||||
version: 8.61.1
|
||||
@@ -41,6 +54,12 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@ark/schema@0.56.1':
|
||||
resolution: {integrity: sha512-1Cf2g9nKD8K/3JGRu+gCCfYw5d4qR8YLLjDs5W5kpmaButCYWAPFUJqSXyBATPjglzCd4tIkp398iPYVs8MjRA==}
|
||||
|
||||
'@ark/util@0.56.1':
|
||||
resolution: {integrity: sha512-Tp1rTik3q5Z+jAeeDxr5JZpmVIw0miti1ykSEHyZv5Pw3TIJl2xbN6KTacOxITp0l3s9ytlrWd30Zvqcy5vzoQ==}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -393,6 +412,9 @@ packages:
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
'@types/picomatch@4.0.3':
|
||||
resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.1':
|
||||
resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -494,6 +516,12 @@ packages:
|
||||
ajv@6.15.0:
|
||||
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
|
||||
|
||||
arkregex@0.0.7:
|
||||
resolution: {integrity: sha512-O/Ltrn9EUSn3ui0KVzfyrWGDUsHlzKxDVBtpQxL/6JmLRMAZAebfSNf/A/J5Ny5S6QIwrXX+RfXsu888HMs35A==}
|
||||
|
||||
arktype@2.2.2:
|
||||
resolution: {integrity: sha512-YYf1xhL2dh5aPZFlsY0RAsxv5HZqfLGLptH2ZP3JidTmsGRW8VOymhPjjMTkerL12vR2YtX0SK4c1mATtae8SA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -510,6 +538,9 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
citty@0.2.2:
|
||||
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -801,8 +832,8 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
picomatch@4.0.5:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.15:
|
||||
@@ -1003,6 +1034,12 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ark/schema@0.56.1':
|
||||
dependencies:
|
||||
'@ark/util': 0.56.1
|
||||
|
||||
'@ark/util@0.56.1': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -1233,6 +1270,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/picomatch@4.0.3': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -1378,6 +1417,16 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
arkregex@0.0.7:
|
||||
dependencies:
|
||||
'@ark/util': 0.56.1
|
||||
|
||||
arktype@2.2.2:
|
||||
dependencies:
|
||||
'@ark/schema': 0.56.1
|
||||
'@ark/util': 0.56.1
|
||||
arkregex: 0.0.7
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
@@ -1388,6 +1437,8 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
citty@0.2.2: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -1515,9 +1566,9 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
@@ -1667,7 +1718,7 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
postcss@8.5.15:
|
||||
dependencies:
|
||||
@@ -1724,8 +1775,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
picomatch: 4.0.5
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
@@ -1762,7 +1813,7 @@ snapshots:
|
||||
vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
postcss: 8.5.15
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
@@ -1786,7 +1837,7 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { DEFAULT_OPTIONS } from "./options";
|
||||
|
||||
let r = (...[]: [Context]) => {
|
||||
/* empty */
|
||||
};
|
||||
let promise = new Promise<Context>((res) => (r = res));
|
||||
|
||||
const command = defineCommand({
|
||||
meta: {
|
||||
name: "gen-doctests",
|
||||
description:
|
||||
"A generator for tests from @example tags in jsdoc",
|
||||
},
|
||||
args: {
|
||||
include: {
|
||||
type: "positional",
|
||||
required: true,
|
||||
description: "Patterns of files to detect tests in",
|
||||
valueHint: "FILES",
|
||||
},
|
||||
exclude: {
|
||||
type: "string",
|
||||
description:
|
||||
"Patterns of files to ignore when detecting tests",
|
||||
valueHint: "files",
|
||||
alias: ["x"],
|
||||
},
|
||||
["out-dir"]: {
|
||||
type: "string",
|
||||
description:
|
||||
"Output directory to emit generated tests to (emits alongside source files, if unspecified)",
|
||||
alias: ["o"],
|
||||
valueHint: "path",
|
||||
},
|
||||
["test-extension"]: {
|
||||
type: "string",
|
||||
description: "Extension to add to emitted test files",
|
||||
default: DEFAULT_OPTIONS.fileExtension,
|
||||
valueHint: "extension",
|
||||
},
|
||||
["test-name"]: {
|
||||
type: "string",
|
||||
description:
|
||||
"Name to use for tests (inferred from source if not specified)",
|
||||
valueHint: "name",
|
||||
},
|
||||
["template-root"]: {
|
||||
type: "boolean",
|
||||
description:
|
||||
'If enabled, replace any instance of "$PROJECT_ROOT" with the relative path to the root of the project',
|
||||
default: DEFAULT_OPTIONS.templateRoot,
|
||||
},
|
||||
["template-header"]: {
|
||||
type: "string",
|
||||
description: "Line(s) to prepend test file content",
|
||||
valueHint: "line",
|
||||
alias: ["H"],
|
||||
},
|
||||
["template-footer"]: {
|
||||
type: "string",
|
||||
description: "Line(s) to append after test file content",
|
||||
valueHint: "line",
|
||||
alias: ["F"],
|
||||
},
|
||||
format: {
|
||||
type: "enum",
|
||||
options: ["jest", "vitest", "assert"],
|
||||
description: "Test format to emit",
|
||||
default: DEFAULT_OPTIONS.format,
|
||||
alias: ["f"],
|
||||
},
|
||||
["must-assert"]: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If enabled, tests are only emitted for examples which make assertions using the test harness (e.g. expect() for jest/vitest)",
|
||||
default: DEFAULT_OPTIONS.onlyGenerateTests,
|
||||
},
|
||||
["must-codeblock"]: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If enabled, only tests surrounded by codeblocks are emitted",
|
||||
default: DEFAULT_OPTIONS.requireCodeBlock,
|
||||
},
|
||||
annotate: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If enabled, adds comments to the test file documenting line numbers and files",
|
||||
default: DEFAULT_OPTIONS.includePath,
|
||||
},
|
||||
},
|
||||
run(context) {
|
||||
r(context);
|
||||
},
|
||||
});
|
||||
|
||||
type Context = typeof command.run extends
|
||||
| ((context: infer T) => unknown)
|
||||
| undefined
|
||||
? T | undefined
|
||||
: never;
|
||||
|
||||
export const runCli = async () => {
|
||||
const _ = runMain(command);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
promise = new Promise<Context>((res) => (r = res));
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import type { Options } from "./options";
|
||||
import { TestState, type File } from "./parser";
|
||||
import picomatch from "picomatch";
|
||||
import { dirname, join, relative } from "path";
|
||||
|
||||
interface Region {
|
||||
[K: string]: Region;
|
||||
[K: symbol]: string;
|
||||
}
|
||||
|
||||
const javascript = [
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"jsx",
|
||||
"javascript",
|
||||
"javascriptx",
|
||||
"ts",
|
||||
"mts",
|
||||
"cts",
|
||||
"tsx",
|
||||
"typescriptx",
|
||||
];
|
||||
|
||||
const harnesses = {
|
||||
assert: {
|
||||
test: (src, name) => `// ${name}\n(() => {\n${src}\n})()`,
|
||||
skip: (src, name) => `// ${name}\n(() => {\n${src}\n})`,
|
||||
fail: (src, name) =>
|
||||
[
|
||||
`// ${name}`,
|
||||
"{",
|
||||
"\tlet __threw = false;",
|
||||
"\ttry {",
|
||||
"\t\t(() => {",
|
||||
src
|
||||
.split("\n")
|
||||
.map((line) => "\t" + line)
|
||||
.join("\n"),
|
||||
"\t\t})()",
|
||||
"\t} catch(e) {",
|
||||
"\t\t__threw = true",
|
||||
"\t}",
|
||||
`\tif(!__threw) throw new Error("Test '${name}' failed")`,
|
||||
"}",
|
||||
]
|
||||
.flat()
|
||||
.join("\n"),
|
||||
region: (src, name) =>
|
||||
`// #region ${name}\n\n${src}\n\n// #endregion ${name}`,
|
||||
qualifies: (src) =>
|
||||
src.includes("assert") || src.includes("throw"),
|
||||
extensions: javascript,
|
||||
indent: 2,
|
||||
},
|
||||
jest: {
|
||||
test: (src, name) => `test("${name}", () => {\n${src}\n})`,
|
||||
skip: (src, name) =>
|
||||
`test.skip("${name}", () => {\n${src}\n})`,
|
||||
fail: (src, name) =>
|
||||
`test.failing("${name}", () => {\n${src}\n})`,
|
||||
region: (src, name) =>
|
||||
`describe("${name}", () => {\n${src
|
||||
.split("\n")
|
||||
.map((line) => "\t" + line)
|
||||
.join("\n")}\n})`,
|
||||
qualifies: (src) =>
|
||||
src.includes("expect") || src.includes("jest"),
|
||||
extensions: javascript,
|
||||
indent: 1,
|
||||
},
|
||||
vitest: {
|
||||
test: (src, name) => `test("${name}", () => {\n${src}\n})`,
|
||||
skip: (src, name) =>
|
||||
`test.skip("${name}", () => {\n${src}\n})`,
|
||||
fail: (src, name) =>
|
||||
`test.fails("${name}", () => {\n${src}\n})`,
|
||||
region: (src, name) =>
|
||||
`describe("${name}", () => {\n${src
|
||||
.split("\n")
|
||||
.map((line) => "\t" + line)
|
||||
.join("\n")}\n})`,
|
||||
qualifies: (src) =>
|
||||
src.includes("expect") || src.includes("vitest"),
|
||||
extensions: javascript,
|
||||
indent: 1,
|
||||
},
|
||||
} satisfies Record<
|
||||
Options["format"],
|
||||
Record<
|
||||
"test" | "skip" | "fail" | "region",
|
||||
(src: string, name: string) => string
|
||||
> & {
|
||||
qualifies: (src: string) => boolean;
|
||||
extensions: readonly string[];
|
||||
indent?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
export function createGenerator(options: Options) {
|
||||
if (options.outDir !== null)
|
||||
mkdirSync(options.outDir, { recursive: true });
|
||||
|
||||
const isInclude = picomatch(options.include);
|
||||
const isExclude = picomatch(options.exclude);
|
||||
|
||||
const harness = harnesses[options.format];
|
||||
|
||||
return (file: File) => {
|
||||
if (!isInclude(file.filePath) || isExclude(file.filePath))
|
||||
return;
|
||||
|
||||
let source = "";
|
||||
|
||||
const regions: Region = {};
|
||||
|
||||
const filename =
|
||||
file.name + options.fileExtension + file.extension;
|
||||
const dir = options.outDir ?? dirname(file.filePath);
|
||||
|
||||
const root = relative(dir, ".");
|
||||
|
||||
if (options.includePath)
|
||||
source +=
|
||||
"// Automatically generated doc-tests for " +
|
||||
join(root, file.filePath) +
|
||||
"\n\n";
|
||||
|
||||
if (options.templateHeader.length > 0)
|
||||
source += options.templateHeader.join("\n") + "\n\n";
|
||||
|
||||
for (const test of file.tests) {
|
||||
if (
|
||||
options.onlyGenerateTests &&
|
||||
!harness.qualifies(test.source)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
test.extension !== null &&
|
||||
!harness.extensions.includes(test.extension)
|
||||
)
|
||||
continue;
|
||||
|
||||
let src = test.source;
|
||||
const lines = src.split("\n");
|
||||
let i;
|
||||
for (i = src.length; i > 0; i--) {
|
||||
const substring = src.slice(0, i);
|
||||
if (
|
||||
!substring
|
||||
.split("")
|
||||
.every((c) => c === " " || c === "\t")
|
||||
)
|
||||
continue;
|
||||
if (lines.every((line) => line.startsWith(substring)))
|
||||
break;
|
||||
}
|
||||
src = src
|
||||
.split("\n")
|
||||
.map((line) => line.slice(i))
|
||||
.join("\n");
|
||||
|
||||
if (options.includePath)
|
||||
src =
|
||||
`// ${join(root, file.filePath)}:${test.line.toString()}\n` +
|
||||
src;
|
||||
|
||||
const indent = "\t".repeat(harness.indent);
|
||||
src = src
|
||||
.split("\n")
|
||||
.map((line) => indent + line)
|
||||
.join("\n");
|
||||
|
||||
const name = options.testName ?? test.name;
|
||||
|
||||
let fn;
|
||||
if (test.state === TestState.Run) fn = harness.test;
|
||||
else if (test.state === TestState.Ignore)
|
||||
fn = harness.skip;
|
||||
else fn = harness.fail;
|
||||
|
||||
let node: Region | null = null;
|
||||
for (const region of test.namespace) {
|
||||
regions[region] ??= {};
|
||||
node = regions[region];
|
||||
}
|
||||
|
||||
const out = fn(src, name) + "\n\n";
|
||||
if (node !== null) node[Symbol()] = out;
|
||||
else source += out;
|
||||
}
|
||||
|
||||
function emitRegions(region: Region): string {
|
||||
const tests: string[] = [];
|
||||
const regions: string[] = [];
|
||||
|
||||
console.log(region);
|
||||
|
||||
for (const key of Reflect.ownKeys(region)) {
|
||||
const value = region[key];
|
||||
if (typeof value === "string") {
|
||||
tests.push(value);
|
||||
} else if (
|
||||
typeof value === "object" &&
|
||||
typeof key === "string"
|
||||
) {
|
||||
regions.push(
|
||||
harness.region(emitRegions(value), key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...tests, ...regions].join("\n\n").trim();
|
||||
}
|
||||
|
||||
source += emitRegions(regions);
|
||||
|
||||
if (options.templateFooter.length > 0)
|
||||
source += "\n\n" + options.templateFooter.join("\n");
|
||||
|
||||
source = source.replaceAll("$PROJECT_ROOT", root);
|
||||
|
||||
writeFileSync(join(dir, filename), source, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
};
|
||||
}
|
||||
+58
-8
@@ -1,10 +1,60 @@
|
||||
function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
import { globSync } from "fs";
|
||||
import { runCli } from "./cli";
|
||||
import { DEFAULT_OPTIONS } from "./options";
|
||||
import { parseFile } from "./parser";
|
||||
import { createGenerator } from "./generator";
|
||||
|
||||
async function main() {
|
||||
const result = await runCli();
|
||||
if (result === undefined) return;
|
||||
|
||||
const include = result.rawArgs;
|
||||
if (include.length === 0) {
|
||||
console.info("No work to do");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const options = DEFAULT_OPTIONS;
|
||||
|
||||
options.include = include;
|
||||
|
||||
const { args } = result;
|
||||
options.outDir = args["out-dir"] ?? DEFAULT_OPTIONS.outDir;
|
||||
options.fileExtension = args["test-extension"];
|
||||
options.testName = args["test-name"] ?? DEFAULT_OPTIONS.testName;
|
||||
options.templateRoot = args["template-root"];
|
||||
options.templateHeader =
|
||||
args["template-header"] !== undefined
|
||||
? [args["template-header"]]
|
||||
: DEFAULT_OPTIONS.templateHeader;
|
||||
options.templateFooter =
|
||||
args["template-header"] !== undefined
|
||||
? [args["template-header"]]
|
||||
: DEFAULT_OPTIONS.templateFooter;
|
||||
options.format = args.format;
|
||||
options.onlyGenerateTests = args["must-assert"];
|
||||
options.requireCodeBlock = args["must-codeblock"];
|
||||
options.includePath = args.annotate;
|
||||
|
||||
const files = globSync(include);
|
||||
const parsed = files
|
||||
.map((path) => parseFile(path, options))
|
||||
.filter((v) => v !== null);
|
||||
|
||||
console.info(
|
||||
"Parsed",
|
||||
parsed.map((f) => f.tests).flat().length,
|
||||
"tests across",
|
||||
parsed.length,
|
||||
"files",
|
||||
);
|
||||
|
||||
const generator = createGenerator(options);
|
||||
parsed.forEach(generator);
|
||||
}
|
||||
|
||||
if (import.meta.vitest) {
|
||||
const { it, expect } = import.meta.vitest;
|
||||
it("adds", () => {
|
||||
expect(add(1, 2)).toBe(3);
|
||||
});
|
||||
}
|
||||
if (import.meta.main) await main();
|
||||
|
||||
export type { Options } from "./options";
|
||||
export { parseFile } from "./parser";
|
||||
export { createGenerator } from "./generator";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface Options {
|
||||
/**
|
||||
* Pattern matching files to include in parsing
|
||||
*/
|
||||
include: string[];
|
||||
/**
|
||||
* Pattern matching files to ignore when parsing
|
||||
*/
|
||||
exclude: string[];
|
||||
/**
|
||||
* Output directory, test files are emitted alongside their source files if
|
||||
* not specified
|
||||
*/
|
||||
outDir: string | null;
|
||||
/**
|
||||
* File extension to emit test files with
|
||||
* @default
|
||||
* ".doc.test"
|
||||
*/
|
||||
fileExtension: string;
|
||||
/**
|
||||
* Name to use for tests. If not specified, then tests are named by what
|
||||
* their jsdoc comments are documenting.
|
||||
*/
|
||||
testName: string | null;
|
||||
/**
|
||||
* Whether to replace any instance of `$PROJECT_ROOT` with a relative file path to
|
||||
* the root of the project (e.g., for imports)
|
||||
*/
|
||||
templateRoot: boolean;
|
||||
/**
|
||||
* Lines to insert before the test file contents
|
||||
*/
|
||||
templateHeader: string[];
|
||||
/**
|
||||
* Lines to insert after the test file contents
|
||||
*/
|
||||
templateFooter: string[];
|
||||
/**
|
||||
* Test format to emit
|
||||
* - `jest`/`vitest`: Generate `test(...)` and `describe(...)` calls with `expect(...)`
|
||||
* - `assert`: Generate code to run `console.assert(...)`/`assert(...)`-style tests
|
||||
*/
|
||||
format: "jest" | "vitest" | "assert";
|
||||
/**
|
||||
* Whether or not tests only containing assertions should be emitted
|
||||
*
|
||||
* The assertion syntax is determined by {@link format}, e.g. `expect(...)`
|
||||
* for Jest/Vitest.
|
||||
*/
|
||||
onlyGenerateTests: boolean;
|
||||
/**
|
||||
* Whether to require a markdown code block with an appropriate language to
|
||||
* generate tests
|
||||
*/
|
||||
requireCodeBlock: boolean;
|
||||
/**
|
||||
* Whether to include a comment with a path to the test source file in each
|
||||
* test
|
||||
*/
|
||||
includePath: boolean;
|
||||
/**
|
||||
* Whether or not to emit test regions for namespaces using the test harness
|
||||
* (e.g. `describe()` regions in jest/vitest).
|
||||
*/
|
||||
emitRegions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPTIONS: Required<Options> = {
|
||||
include: [],
|
||||
exclude: [],
|
||||
outDir: null,
|
||||
fileExtension: ".doc.test",
|
||||
testName: null,
|
||||
templateRoot: true,
|
||||
templateHeader: [],
|
||||
templateFooter: [],
|
||||
format: "vitest",
|
||||
onlyGenerateTests: false,
|
||||
requireCodeBlock: false,
|
||||
includePath: true,
|
||||
emitRegions: true,
|
||||
};
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
import picomatch from "picomatch";
|
||||
import type { Options } from "./options";
|
||||
import ts from "typescript";
|
||||
import { readFileSync } from "fs";
|
||||
import { basename, extname } from "path";
|
||||
|
||||
export interface File {
|
||||
name: string;
|
||||
extension: string;
|
||||
tests: Test[];
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export const TestState = {
|
||||
Run: 0,
|
||||
Ignore: 1,
|
||||
Fails: 2,
|
||||
} as const;
|
||||
export type TestState = (typeof TestState)[keyof typeof TestState];
|
||||
|
||||
export const PathFragmentType = {
|
||||
Namespace: 0,
|
||||
IndexSignature: 1,
|
||||
Function: 2,
|
||||
Other: 3,
|
||||
} as const;
|
||||
export type PathFragmentType =
|
||||
(typeof PathFragmentType)[keyof typeof PathFragmentType];
|
||||
|
||||
export interface PathFragment {
|
||||
kind: PathFragmentType;
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
export interface Test {
|
||||
name: string;
|
||||
source: string;
|
||||
line: number;
|
||||
extension: string | null;
|
||||
state: TestState;
|
||||
namespace: string[];
|
||||
}
|
||||
|
||||
export function parseFile(
|
||||
filePath: string,
|
||||
options: Options,
|
||||
): File | null {
|
||||
if (options.exclude.some((glob) => picomatch(glob)(filePath)))
|
||||
return null;
|
||||
|
||||
const lines: number[] = [];
|
||||
function posToLine(pos: number) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (pos <= lines[i]) return i + 1;
|
||||
}
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
const tests: Test[] = [];
|
||||
const extension = extname(filePath);
|
||||
|
||||
function visit(node: ts.Node, path: PathFragment[] = []) {
|
||||
const newPath = [...path];
|
||||
|
||||
let visitThis = true;
|
||||
|
||||
let name: string | null = null;
|
||||
let kind: PathFragmentType | null = null;
|
||||
|
||||
if (ts.isFunctionDeclaration(node)) {
|
||||
if (node.name) {
|
||||
name = node.name.text;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.Function,
|
||||
fragment: name,
|
||||
});
|
||||
}
|
||||
kind = PathFragmentType.Function;
|
||||
visitThis = false;
|
||||
if (node.body) {
|
||||
node.body.statements.forEach((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
}
|
||||
} else if (ts.isVariableStatement(node)) {
|
||||
const decleration = node.declarationList.declarations[0];
|
||||
if (ts.isIdentifier(decleration.name))
|
||||
name = decleration.name.text;
|
||||
kind = PathFragmentType.Other;
|
||||
|
||||
if (
|
||||
ts.hasOnlyExpressionInitializer(decleration) &&
|
||||
decleration.initializer &&
|
||||
ts.isFunctionLike(decleration.initializer)
|
||||
)
|
||||
kind = PathFragmentType.Function;
|
||||
|
||||
visitThis = false;
|
||||
node.declarationList.declarations.forEach(
|
||||
(decleration) => {
|
||||
let path = newPath;
|
||||
if (ts.isIdentifier(decleration.name))
|
||||
path = [
|
||||
...path,
|
||||
{
|
||||
kind: PathFragmentType.Other,
|
||||
fragment: decleration.name.text,
|
||||
},
|
||||
];
|
||||
if (
|
||||
!ts.hasOnlyExpressionInitializer(
|
||||
decleration,
|
||||
) ||
|
||||
!decleration.initializer
|
||||
)
|
||||
return;
|
||||
decleration.initializer.forEachChild((node) => {
|
||||
visit(node, path);
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (ts.isPropertyAssignment(node)) {
|
||||
const identifier = getIdentifier(node.name);
|
||||
if (identifier !== null) {
|
||||
name = identifier;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.Other,
|
||||
fragment: identifier,
|
||||
});
|
||||
}
|
||||
kind = PathFragmentType.Other;
|
||||
visitThis = false;
|
||||
node.initializer.forEachChild((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
} else if (ts.isModuleDeclaration(node)) {
|
||||
let text;
|
||||
if (ts.isIdentifier(node.name)) text = node.name.text;
|
||||
else if (ts.isStringLiteralLike(node.name))
|
||||
text = node.name.text;
|
||||
kind = PathFragmentType.Namespace;
|
||||
|
||||
if (text !== undefined) {
|
||||
name = text;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.Namespace,
|
||||
fragment: name,
|
||||
});
|
||||
}
|
||||
} else if (ts.isInterfaceDeclaration(node)) {
|
||||
name = node.name.text;
|
||||
kind = PathFragmentType.Namespace;
|
||||
newPath.push({ kind, fragment: name });
|
||||
|
||||
visitThis = false;
|
||||
node.members.forEach((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
} else if (ts.isPropertySignature(node)) {
|
||||
const identifier = getIdentifier(node.name);
|
||||
kind = PathFragmentType.Other;
|
||||
if (identifier !== null) {
|
||||
name = identifier;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.Other,
|
||||
fragment: identifier,
|
||||
});
|
||||
} else if (
|
||||
ts.isComputedPropertyName(node.name) &&
|
||||
ts.isBinaryExpression(node.name.expression)
|
||||
) {
|
||||
name = "unknown";
|
||||
kind = PathFragmentType.IndexSignature;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.IndexSignature,
|
||||
fragment: "unknown",
|
||||
});
|
||||
}
|
||||
visitThis = false;
|
||||
if (node.type)
|
||||
node.type.forEachChild((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
} else if (ts.isIndexSignatureDeclaration(node)) {
|
||||
const type = node.parameters[0].type;
|
||||
let contents = undefined;
|
||||
switch (type?.kind) {
|
||||
case ts.SyntaxKind.StringKeyword:
|
||||
contents = "string";
|
||||
break;
|
||||
case ts.SyntaxKind.NumberKeyword:
|
||||
contents = "number";
|
||||
break;
|
||||
case ts.SyntaxKind.SymbolKeyword:
|
||||
contents = "symbol";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (contents !== undefined) {
|
||||
name = contents;
|
||||
newPath.push({
|
||||
kind: PathFragmentType.IndexSignature,
|
||||
fragment: contents,
|
||||
});
|
||||
}
|
||||
kind = PathFragmentType.IndexSignature;
|
||||
visitThis = false;
|
||||
node.type.forEachChild((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
}
|
||||
|
||||
if (visitThis)
|
||||
node.forEachChild((node) => {
|
||||
visit(node, newPath);
|
||||
});
|
||||
|
||||
const examples = ts
|
||||
.getJSDocCommentsAndTags(node)
|
||||
.map((v) => (ts.isJSDoc(v) ? (v.tags ?? []) : v))
|
||||
.flat()
|
||||
.filter((tag) => tag.tagName.text === "example");
|
||||
if (examples.length === 0) return;
|
||||
|
||||
let p = [...path];
|
||||
if (name !== null && kind !== null)
|
||||
p.push({ kind, fragment: name });
|
||||
|
||||
const pOld = p;
|
||||
|
||||
const namespace = [];
|
||||
|
||||
while (
|
||||
p[0].kind === PathFragmentType.Namespace &&
|
||||
p.length > 1
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
namespace.push(p.shift()!.fragment);
|
||||
}
|
||||
|
||||
if (!options.emitRegions) p = pOld;
|
||||
|
||||
name = computeName(p);
|
||||
|
||||
for (const example of examples) {
|
||||
if (example.comment === undefined) continue;
|
||||
const content = (
|
||||
typeof example.comment === "string"
|
||||
? example.comment
|
||||
: example.comment
|
||||
.filter(
|
||||
(node) =>
|
||||
node.kind ===
|
||||
ts.SyntaxKind.JSDocText,
|
||||
)
|
||||
.map((node) => node.text)
|
||||
.join(" ")
|
||||
).trim();
|
||||
|
||||
let testSrc = "";
|
||||
|
||||
if (content.includes("```")) {
|
||||
let extension: string | null = null;
|
||||
let state: TestState = TestState.Run;
|
||||
|
||||
let [, block, rest] = split(content, "```", 2);
|
||||
while ((rest as string | undefined) !== undefined) {
|
||||
const [header, src] = split(block, "\n", 1);
|
||||
|
||||
if (header.trim() !== "") {
|
||||
const [ext, ...rest] = header
|
||||
.trim()
|
||||
.split(" ");
|
||||
extension = ext;
|
||||
|
||||
if (
|
||||
rest.includes("ignore") ||
|
||||
rest.includes("skip") ||
|
||||
rest.includes("skipped") ||
|
||||
rest.includes("notest") ||
|
||||
rest.includes("no-test")
|
||||
) {
|
||||
state = TestState.Ignore;
|
||||
} else if (
|
||||
rest.includes("fail") ||
|
||||
rest.includes("failure") ||
|
||||
rest.includes("fails") ||
|
||||
rest.includes("throw") ||
|
||||
rest.includes("throws")
|
||||
) {
|
||||
state = TestState.Fails;
|
||||
}
|
||||
}
|
||||
|
||||
testSrc +=
|
||||
"{\n" +
|
||||
src
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => "\t" + line)
|
||||
.join("\n") +
|
||||
"\n}";
|
||||
|
||||
[, block, rest] = split(rest, "```", 2);
|
||||
}
|
||||
tests.push({
|
||||
name,
|
||||
source: testSrc,
|
||||
line: posToLine(example.pos),
|
||||
extension,
|
||||
state,
|
||||
namespace,
|
||||
});
|
||||
} else {
|
||||
if (options.requireCodeBlock) continue;
|
||||
tests.push({
|
||||
name,
|
||||
source: content,
|
||||
line: posToLine(example.pos),
|
||||
extension: null,
|
||||
state: TestState.Run,
|
||||
namespace,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const source = readFileSync(filePath, { encoding: "utf-8" });
|
||||
|
||||
let sum = 0;
|
||||
for (const line of source.split("\n")) {
|
||||
sum += line.length;
|
||||
lines.push(sum);
|
||||
}
|
||||
|
||||
const node = ts.createSourceFile(
|
||||
basename(filePath),
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
extension === "jsx" || extension == "tsx"
|
||||
? ts.ScriptKind.TSX
|
||||
: ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
visit(node);
|
||||
|
||||
console.log(tests);
|
||||
|
||||
return {
|
||||
name: basename(filePath, extension),
|
||||
extension: extension,
|
||||
filePath,
|
||||
tests,
|
||||
};
|
||||
}
|
||||
|
||||
function getIdentifier(node: ts.Node): string | null {
|
||||
if (ts.isIdentifier(node)) return node.text;
|
||||
if (ts.isStringLiteralLike(node)) return node.text;
|
||||
if (ts.isNumericLiteral(node)) return node.text;
|
||||
if (ts.isComputedPropertyName(node))
|
||||
return getIdentifier(node.expression);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeName(path: PathFragment[]): string {
|
||||
if (path.length === 0) return "unknown";
|
||||
if (path.length === 1) {
|
||||
const first = path[0];
|
||||
return first.kind === PathFragmentType.Function
|
||||
? `${first.fragment}()`
|
||||
: first.kind === PathFragmentType.IndexSignature
|
||||
? `[${first.fragment}]`
|
||||
: first.fragment;
|
||||
}
|
||||
|
||||
const last = path[path.length - 1];
|
||||
const parent = path.slice(0, -1);
|
||||
|
||||
const parentName = computeName(parent);
|
||||
|
||||
const simpleParent = parent.every(
|
||||
(p) =>
|
||||
p.kind === PathFragmentType.Namespace ||
|
||||
p.kind === PathFragmentType.Function ||
|
||||
p.kind === PathFragmentType.IndexSignature,
|
||||
);
|
||||
|
||||
if (simpleParent) {
|
||||
switch (last.kind) {
|
||||
case PathFragmentType.IndexSignature:
|
||||
return `${parentName}[${last.fragment}]`;
|
||||
|
||||
case PathFragmentType.Function:
|
||||
return `${parentName}.${last.fragment}()`;
|
||||
|
||||
default:
|
||||
return `${parentName}.${last.fragment}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${
|
||||
last.kind === PathFragmentType.Function
|
||||
? `${last.fragment}()`
|
||||
: last.kind === PathFragmentType.IndexSignature
|
||||
? `[${last.fragment}]`
|
||||
: last.fragment
|
||||
} (in ${parentName})`;
|
||||
}
|
||||
|
||||
function split(s: string, pattern: string, count: number) {
|
||||
const out: string[] = [];
|
||||
while (count > 0 && s.includes(pattern)) {
|
||||
const index = s.indexOf(pattern);
|
||||
out.push(s.slice(0, index));
|
||||
s = s.slice(index + pattern.length);
|
||||
count -= 1;
|
||||
}
|
||||
return [...out, s];
|
||||
}
|
||||
+1
-2
@@ -4,7 +4,7 @@
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["vitest/importMeta"],
|
||||
"types": ["vitest/importMeta", "node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
|
||||
Reference in New Issue
Block a user