feat: basically the whole package

This commit is contained in:
2026-07-04 20:17:41 +02:00
parent 5b23824a56
commit b6c8c93430
9 changed files with 979 additions and 20 deletions
+8
View File
@@ -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
View File
@@ -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"
}
}
+60 -9
View File
@@ -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
View File
@@ -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;
};
+228
View File
@@ -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",
});
};
}
+57 -7
View File
@@ -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);
}
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest;
it("adds", () => {
expect(add(1, 2)).toBe(3);
});
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.main) await main();
export type { Options } from "./options";
export { parseFile } from "./parser";
export { createGenerator } from "./generator";
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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"]