diff --git a/.config/build.js b/.config/build.js new file mode 100644 index 0000000..27f80d5 --- /dev/null +++ b/.config/build.js @@ -0,0 +1,18 @@ +import * as esbuild from "esbuild"; + +const result = await esbuild.build({ + entryPoints: ["src/index.ts", "src/config.ts"], + minify: true, + bundle: true, + outdir: "dist", + define: { + ["import.meta.vitest"]: "undefined", + }, + format: "esm", + platform: "node", + packages: "external", +}); + +if (result.errors.length !== 0) { + throw result; +} diff --git a/README.md b/README.md index e69de29..5f70126 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# gen-doctests + +Generate tests from your JSDoc documentation for whichever testing harness you prefer. + +## Usage + +> See `gen-doctests --help` for more detail. + +Write a doctest alongside your source code: + +````ts +// src/isEven.ts + +/** + * Returns whether the specified number is divisible by 2 + * @param x The number to check + * @example + * ```ts + * expect(isEven(5)).toBe(false); + * expect(isEven(4)).toBe(true); + * ``` + */ +export function isEven(x: number) { + return x % 2 === 0; +} +```` + +And generate your doctests using either the library or the CLI: + +```bash +$ gen-doctests src/**.ts --format vitest -o tests/generated +``` + +With the above produces the following test file: + +```ts +// tests/generated/isEven.doc.test.ts + +// Automatically generated tests for ../../src/isEven.ts + +import { describe, expect, test, vi } from "vitest"; +import { isEven } from "../../src/isEven"; + +test("isEven()", () => { + expect(isEven(5)).toBe(false); + expect(isEven(4)).toBe(true); +}); +``` + +You can also define a `doctests.config.ts` file to skip the command arguments: + +```ts +// doctests.config.ts +import { defineConfig } from "gen-doctests/config"; + +export default defineConfig({ + include: ["src/**.{js,jsx,ts,tsx}"], + outDir: "dist/", +}); +``` diff --git a/doctests.config.ts b/doctests.config.ts new file mode 100644 index 0000000..21fd530 --- /dev/null +++ b/doctests.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "./dist/config"; + +export default defineConfig({ + include: ["src/**.ts"], + templateHeader: ["console.log('hello, world!');"], +}); diff --git a/package.json b/package.json index ddc983b..e1767bf 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { "name": "gen-doctests", "private": false, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "bin": "dist/index.js", "scripts": { "test": "vitest --run --reporter=tree", - "build": "tsc -b && esbuild --minify --bundle src/index.ts --outdir=dist --define:import.meta.vitest=undefined --platform=node --format=esm --packages=external", - "prepublishOnly": "pnpm build", + "build": "tsc -b && node .config/build.js", + "prepublishOnly": "pnpm test && pnpm fmt && pnpm build", "fmt": "prettier --write .", "lint": "eslint .", "preview": "vite preview" }, + "exports": { + ".": "./dist/index.js" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.12.3", @@ -19,13 +22,15 @@ "@typescript-eslint/types": "^8.61.1", "esbuild": "^0.28.1", "eslint": "^10.3.0", - "jiti": "^2.7.0", "prettier": "^3.8.4", "typescript-eslint": "^8.59.2", "vitest": "^4.1.9" }, "dependencies": { + "arktype": "^2.2.2", "citty": "^0.2.2", + "find-up": "^8.0.0", + "jiti": "^2.7.0", "picomatch": "^4.0.5", "typescript": "^6.0.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a64e63..11b884c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,18 @@ importers: .: dependencies: + arktype: + specifier: ^2.2.2 + version: 2.2.2 citty: specifier: ^0.2.2 version: 0.2.2 + find-up: + specifier: ^8.0.0 + version: 8.0.0 + jiti: + specifier: ^2.7.0 + version: 2.7.0 picomatch: specifier: ^4.0.5 version: 4.0.5 @@ -36,9 +45,6 @@ importers: eslint: specifier: ^10.3.0 version: 10.5.0(jiti@2.7.0) - jiti: - specifier: ^2.7.0 - version: 2.7.0 prettier: specifier: ^3.8.4 version: 3.8.4 @@ -51,6 +57,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==} @@ -507,6 +519,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'} @@ -636,6 +654,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@8.0.0: + resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} + engines: {node: '>=20'} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -769,6 +791,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@8.0.0: + resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} + engines: {node: '>=20'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -799,10 +825,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -912,6 +946,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1017,8 +1055,18 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + 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 @@ -1396,6 +1444,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: {} @@ -1548,6 +1606,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@8.0.0: + dependencies: + locate-path: 8.0.0 + unicorn-magic: 0.3.0 + flat-cache@4.0.1: dependencies: flatted: 3.4.2 @@ -1646,6 +1709,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@8.0.0: + dependencies: + p-locate: 6.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1675,10 +1742,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -1775,6 +1850,8 @@ snapshots: undici-types@7.18.2: {} + unicorn-magic@0.3.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -1831,3 +1908,5 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cfe472b --- /dev/null +++ b/src/config.ts @@ -0,0 +1 @@ +export { defineConfig, type Options } from "./options"; diff --git a/src/generator.ts b/src/generator.ts index 358131d..4abc0dd 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -111,6 +111,8 @@ export function createGenerator(options: Options) { if (!isInclude(file.filePath) || isExclude(file.filePath)) return; + if (file.tests.length === 0) return; + let source = ""; const regions: Region = {}; @@ -195,8 +197,6 @@ export function createGenerator(options: Options) { const tests: string[] = []; const regions: string[] = []; - console.log(region); - for (const key of Reflect.ownKeys(region)) { const value = region[key]; if (typeof value === "string") { diff --git a/src/index.ts b/src/index.ts index ad18a1a..e92db51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,68 @@ #!/usr/bin/env node -import { globSync } from "fs"; +import { globSync, lstatSync, readFileSync } from "fs"; import { runCli } from "./cli"; -import { DEFAULT_OPTIONS } from "./options"; +import { DEFAULT_OPTIONS, Options } from "./options"; import { parseFile } from "./parser"; import { createGenerator } from "./generator"; +import { createJiti } from "jiti"; +import picomatch from "picomatch"; +import { findUp } from "find-up"; +import { basename } from "path"; +import { pathToFileURL } from "url"; +import { type } from "arktype"; async function main() { + const configFilePath = await findUp([ + "doctests.config.ts", + "doctest.config.ts", + "doctests.config.js", + "doctest.config.js", + ]); + if (process.argv.length <= 2 && configFilePath !== undefined) { + const jit = createJiti(import.meta.url); + const config = readFileSync(configFilePath, { + encoding: "utf-8", + }); + + const configFile = basename(configFilePath); + const result = jit.evalModule(config, { + filename: pathToFileURL(configFile).toString(), + }); + + if ( + typeof result === "object" && + result !== null && + "default" in result && + typeof result.default === "object" && + result.default !== null + ) { + const config = Options(result.default); + + if (config instanceof type.errors) { + console.error( + `error while reading ${configFile}: ${config.summary}`, + ); + return; + } + + parseWithOptions({ + ...DEFAULT_OPTIONS, + ...config, + }); + return; + } else { + console.error( + `error while reading ${configFile}: expected a default export with the config keys`, + ); + console.info( + "see `defineConfig()` from `gen-doctests/config`", + ); + } + + return; + } + const result = await runCli(); if (result === undefined) return; @@ -21,6 +77,10 @@ async function main() { options.include = include; const { args } = result; + options.exclude = + args.exclude !== undefined + ? [args.exclude] + : DEFAULT_OPTIONS.exclude; options.outDir = args["out-dir"] ?? DEFAULT_OPTIONS.outDir; options.fileExtension = args["test-extension"]; options.testName = args["test-name"] ?? DEFAULT_OPTIONS.testName; @@ -38,7 +98,15 @@ async function main() { options.requireCodeBlock = args["must-codeblock"]; options.includePath = args.annotate; - const files = globSync(include); + parseWithOptions(options); +} + +function parseWithOptions(options: Options) { + const doctest = picomatch("**/*" + options.fileExtension + ".*"); + + const files = globSync(options.include) + .filter((v) => !doctest(v)) + .filter((path) => !lstatSync(path).isDirectory()); const parsed = files .map((path) => parseFile(path, options)) .filter((v) => v !== null); diff --git a/src/options.ts b/src/options.ts index cfe481a..73e388b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,3 +1,5 @@ +import { type } from "arktype"; + export interface Options { /** * Pattern matching files to include in parsing @@ -66,6 +68,22 @@ export interface Options { emitRegions: boolean; } +export const Options = type({ + include: "string[]", + "exclude?": "string[]", + "outDir?": "string | null", + "fileExtension?": "string", + "testName?": "string | null", + "templateRoot?": "boolean", + "templateHeader?": "string[]", + "templateFooter?": "string[]", + "format?": '"jest" | "vitest" | "assert"', + "onlyGenerateTests?": "boolean", + "requireCodeBlock?": "boolean", + "includePath?": "boolean", + "emitRegions?": "boolean", +}); + export const DEFAULT_OPTIONS: Required = { include: [], exclude: [], @@ -81,3 +99,13 @@ export const DEFAULT_OPTIONS: Required = { includePath: true, emitRegions: true, }; + +export function defineConfig( + opts: Partial> & + Pick, +): Options { + return { + ...DEFAULT_OPTIONS, + ...opts, + }; +} diff --git a/src/parser.ts b/src/parser.ts index 539c2d2..1278adc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -346,8 +346,6 @@ export function parseFile( visit(node); - console.log(tests); - return { name: basename(filePath, extension), extension: extension, diff --git a/tsconfig.app.json b/tsconfig.app.json index fb9e251..fdb0579 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -11,13 +11,18 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, + "emitDeclarationOnly": true, + "declaration": true, "moduleDetection": "force", - "noEmit": true, "jsx": "react-jsx", /* Linting */ "noUnusedLocals": false, "erasableSyntaxOnly": true, + + "outDir": "./dist", + "rootDir": "./src" }, - "include": ["src", "tests"] + "include": ["src"], + "exclude": ["src/config.ts"] } diff --git a/tsconfig.config.json b/tsconfig.config.json new file mode 100644 index 0000000..cdd56a9 --- /dev/null +++ b/tsconfig.config.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["vitest/importMeta", "node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "emitDeclarationOnly": true, + "declaration": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": false, + "erasableSyntaxOnly": true, + + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/config.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index fb12418..2902acb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.config.json" }, { "path": "./tsconfig.node.json" } ] }