diff --git a/eslint.config.ts b/eslint.config.ts index 1e06e46..cb30815 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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: { diff --git a/package.json b/package.json index 79c5609..be77692 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4d20b..a0265ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4ac6509 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,111 @@ +import { defineCommand, runMain } from "citty"; +import { DEFAULT_OPTIONS } from "./options"; + +let r = (...[]: [Context]) => { + /* empty */ +}; +let promise = new Promise((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((res) => (r = res)); + + return result; +}; diff --git a/src/generator.ts b/src/generator.ts new file mode 100644 index 0000000..358131d --- /dev/null +++ b/src/generator.ts @@ -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", + }); + }; +} diff --git a/src/index.ts b/src/index.ts index 41b8069..4d4cd77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..cfe481a --- /dev/null +++ b/src/options.ts @@ -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 = { + include: [], + exclude: [], + outDir: null, + fileExtension: ".doc.test", + testName: null, + templateRoot: true, + templateHeader: [], + templateFooter: [], + format: "vitest", + onlyGenerateTests: false, + requireCodeBlock: false, + includePath: true, + emitRegions: true, +}; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..539c2d2 --- /dev/null +++ b/src/parser.ts @@ -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]; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index d1fef79..fb9e251 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -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"]