1/**
2 * @since 4.0.0
3 */
4
5import { normalizePath, type Plugin, transformWithOxc } from "vite"
6import type { TestUserConfig } from "vitest/config"
7import * as Protocol from "./Protocol.ts"
8import * as Source from "./Source.ts"
9import { transform } from "./Transform.ts"
10
11const runner = "@effect/doctest/Runner"
12
13const collectorModule = (
14 file: string,
15 snippets: ReadonlyArray<Source.Snippet>,
16 version?: string | undefined
17): string => {
18 const tests = snippets.map((snippet, index) => {
19 const id = JSON.stringify(Protocol.snippetId(file, index, version))
20 const label = JSON.stringify(snippet.name ?? `line ${snippet.line}`)
21 return `test(${label}, () => import(${id}))`
22 })
23
24 return `import { test } from "@effect/doctest/Runtime"\n\n${tests.join("\n\n")}\n`
25}
26
27/**
28 * Creates a Vite plugin that transforms marked documentation snippets into Vitest tests.
29 *
30 * **Details**
31 *
32 * Use Vitest's `includeSource` option to discover files containing `import.meta.vitest`. The plugin collects marked files through a collector module without executing the source module. Every snippet executes in its own module, with imports resolved relative to the original source file. Native in-source tests are not supported.
33 *
34 * @category testing
35 * @since 4.0.0
36 */
37export const plugin = (): Plugin => {
38 const store = new Map<string, ReadonlyArray<Source.Snippet>>()
39 const cache = new Map<string, {
40 readonly version: string | undefined
41 readonly value: Promise<ReadonlyArray<Source.Snippet>>
42 }>()
43
44 const loadExamples = (
45 file: string,
46 version?: string | undefined
47 ): Promise<ReadonlyArray<Source.Snippet>> => {
48 const cached = cache.get(file)
49 if (cached !== undefined && cached.version === version) {
50 return cached.value
51 }
52
53 const loaded = Source.extractFile(file).then((snippets) => {
54 store.set(file, snippets)
55 return snippets
56 })
57
58 cache.set(file, { version, value: loaded })
59 return loaded
60 }
61
62 return {
63 name: "effect-doctest",
64 enforce: "pre",
65 perEnvironmentWatchChangeDuringDev: true,
66 config(config) {
67 if (config.test?.runner !== undefined) {
68 return undefined
69 }
70
71 return { test: { runner } satisfies TestUserConfig }
72 },
73 resolveId(source, importer, options) {
74 const collector = Protocol.request(Protocol.collectorPrefix, source)
75 if (collector !== undefined) {
76 return Protocol.resolvedId("collector", collector)
77 }
78
79 const snippet = Protocol.request(Protocol.snippetPrefix, source)
80 if (snippet !== undefined && store.has(snippet.file)) {
81 return Protocol.resolvedId("snippet", snippet)
82 }
83
84 const parent = importer === undefined ? undefined : Protocol.resolvedRequest(importer)
85 if (parent?.kind !== "snippet") {
86 return null
87 }
88
89 return this.resolve(source, parent.file, { ...options, skipSelf: true })
90 },
91 load(id) {
92 const loaded = Protocol.resolvedRequest(id)
93 if (loaded === undefined) {
94 return null
95 }
96
97 this.addWatchFile(loaded.file)
98 return loadExamples(loaded.file, loaded.version).then(async (snippets) => {
99 if (loaded.kind === "collector") {
100 return collectorModule(loaded.file, snippets, loaded.version)
101 }
102
103 const snippet = loaded.index === undefined ? undefined : snippets[loaded.index]
104 if (snippet === undefined) {
105 throw new Error(`Unknown documentation snippet module '${id}'`)
106 }
107
108 const source = transform(snippet.source, loaded.file, snippet.line)
109 if (Source.isMarkdown(loaded.file)) {
110 // Seed the source map with the snippet's document offset. Oxc removes
111 // these blank lines from the emitted code but preserves their mapping.
112 return transformWithOxc("\n".repeat(snippet.line) + source, loaded.file, { lang: "ts" })
113 }
114 return source
115 })
116 },
117 watchChange(id) {
118 const normalized = normalizePath(id)
119 for (const file of store.keys()) {
120 if (normalizePath(file) === normalized) {
121 cache.delete(file)
122 }
123 }
124 }
125 }
126}