Open History to see the last commit
1/**
2 * Barrel file discovery and export generation for Effect development tools.
3 *
4 * This module provides the `BarrelGenerator` service used by the codegen CLI to
5 * find files annotated with `@barrel` comments and rewrite the generated export
6 * section beneath each annotation. The generator resolves matching modules
7 * relative to each annotated barrel file, copies each module's top-level
8 * `@since` tag into a minimal JSDoc block, and copies `@stability` unless the module
9 * lives under `internal/` or its header is `@internal`. Export paths are normalized
10 * so the produced TypeScript is stable across platforms.
11 *
12 * @since 4.0.0
13 */
14import * as Context from "effect/Context"
15import * as Data from "effect/Data"
16import * as Effect from "effect/Effect"
17import * as FileSystem from "effect/FileSystem"
18import * as Layer from "effect/Layer"
19import * as Path from "effect/Path"
20import type { PlatformError } from "effect/PlatformError"
21import * as Glob from "./Glob.ts"
22
23/**
24 * Error raised when barrel export generation cannot read required module metadata.
25 *
26 * @category errors
27 * @since 4.0.0
28 */
29export class BarrelCodegenError extends Data.TaggedError("BarrelCodegenError")<{
30 readonly path: string
31 readonly reason: string
32}> {}
33
34const findAnnotation = (content: string): { pattern: string; offset: number } | undefined => {
35 const lines = content.split("\n")
36
37 // Find the line containing the annotation
38 let annotationLine = -1
39 let pattern: string | undefined
40 for (let i = 0; i < lines.length; i++) {
41 pattern = parseAnnotation(lines[i])
42 if (pattern !== undefined) {
43 annotationLine = i
44 break
45 }
46 }
47
48 if (annotationLine === -1 || pattern === undefined) {
49 return undefined
50 }
51
52 // Walk forwards to find the end of the comment block
53 let commentEnd = annotationLine
54 while (commentEnd < lines.length - 1 && lines[commentEnd + 1].trimStart().startsWith("//")) {
55 commentEnd++
56 }
57
58 return { pattern, offset: commentEnd + 1 }
59}
60
61const parseAnnotation = (line: string): string | undefined => {
62 const match = line.match(/^\/\/\s*@barrel(?:\((.+)\))?/)
63 if (!match) {
64 return undefined
65 }
66 return match[1] ?? "*.ts"
67}
68
69interface ModuleMetadata {
70 readonly since: string
71 readonly stability: "stable" | "unstable" | "experimental" | undefined
72}
73
74const extractModuleMetadata = (file: string, content: string): Effect.Effect<ModuleMetadata, BarrelCodegenError> => {
75 // allow triple-slash directives before the module JSDoc
76 const block = content.match(/^(?:\s*\/\/\/[^\n]*\n)*\s*(\/\*\*[\s\S]*?\*\/)/)?.[1]
77 if (block === undefined) {
78 return Effect.fail(
79 new BarrelCodegenError({
80 path: file,
81 reason: "missing top-level module JSDoc"
82 })
83 )
84 }
85 const matches = Array.from(block.matchAll(/^\s*\*\s*@since(?:\s+(.*))?$/gm))
86 if (matches.length !== 1) {
87 return Effect.fail(
88 new BarrelCodegenError({
89 path: file,
90 reason: matches.length === 0
91 ? "missing top-level module @since tag"
92 : "multiple top-level module @since tags"
93 })
94 )
95 }
96 const since = matches[0]?.[1]?.trim() ?? ""
97 if (since.length === 0) {
98 return Effect.fail(
99 new BarrelCodegenError({
100 path: file,
101 reason: "empty top-level module @since tag"
102 })
103 )
104 }
105 return Effect.succeed({
106 since,
107 stability: isInternalModule(file, block) ?
108 undefined :
109 /^\s*\*\s*@stability\s+(stable|unstable|experimental)\s*$/m.exec(block)?.[1] as
110 | "stable"
111 | "unstable"
112 | "experimental"
113 | undefined
114 })
115}
116
117const isInternalModule = (file: string, block: string): boolean =>
118 file.split(/[/\\]/).includes("internal") || /^\s*\*\s*@internal\s*$/m.test(block)
119
120const renderExportJSDoc = ({ since, stability }: ModuleMetadata): string =>
121 `/**
122${stability ? ` * @stability ${stability}\n` : ""} * @since ${since}
123 */`
124
125/**
126 * Metadata for a barrel file discovered from a `@barrel` annotation, including the file path, glob pattern, and insertion offset for generated exports.
127 *
128 * @category models
129 * @since 4.0.0
130 */
131export interface BarrelFile {
132 readonly path: string
133 readonly pattern: string
134 readonly offset: number
135}
136
137/**
138 * Service interface for discovering annotated barrel files and regenerating their export contents.
139 *
140 * @category services
141 * @since 4.0.0
142 */
143export interface BarrelGenerator {
144 readonly discoverFiles: (
145 pattern: string,
146 cwd: string
147 ) => Effect.Effect<Array<BarrelFile>, PlatformError | Glob.GlobError>
148 readonly processFile: (file: BarrelFile) => Effect.Effect<void, PlatformError | Glob.GlobError | BarrelCodegenError>
149}
150
151/**
152 * Service tag for barrel file generation.
153 *
154 * @category services
155 * @since 4.0.0
156 */
157export const BarrelGenerator: Context.Service<BarrelGenerator, BarrelGenerator> = Context.Service(
158 "@effect/utils/BarrelGenerator"
159)
160
161/**
162 * Builds the `BarrelGenerator` service, discovering files with `@barrel` annotations and rewriting their generated export sections from matching modules.
163 *
164 * @category layers
165 * @since 4.0.0
166 */
167export const layer: Layer.Layer<BarrelGenerator, never, FileSystem.FileSystem | Path.Path | Glob.Glob> = Effect.gen(
168 function*() {
169 const fs = yield* FileSystem.FileSystem
170 const path = yield* Path.Path
171 const glob = yield* Glob.Glob
172
173 // Convert native path separators to POSIX (forward slashes) for import/export statements
174 const toPosix = path.sep === "/" ? (file: string) => file : (file: string) => file.split(path.sep).join("/")
175
176 const fileToModuleName = (posix: string): string => {
177 const withoutExt = posix.slice(0, -path.extname(posix).length)
178 return withoutExt.replace(/\//g, "_")
179 }
180
181 const processModule = Effect.fn("processModule")(function*(directory: string, file: string) {
182 const fullPath = path.join(directory, file)
183 const posixPath = toPosix(file)
184 const content = yield* fs.readFileString(fullPath)
185 const metadata = yield* extractModuleMetadata(fullPath, content)
186 const moduleName = fileToModuleName(posixPath)
187 return `${renderExportJSDoc(metadata)}\nexport * as ${moduleName} from "./${posixPath}"`
188 })
189
190 const discoverFile = Effect.fn("discoverFile")(function*(file: string) {
191 const content = yield* fs.readFileString(file)
192 const parsed = findAnnotation(content)
193 return parsed ? { path: file, ...parsed } : undefined
194 })
195
196 const discoverFiles = Effect.fn("discoverFiles")(function*(pattern: string, cwd: string) {
197 const indexFiles = yield* glob.glob(pattern, {
198 cwd,
199 dot: false,
200 follow: false,
201 nodir: true
202 })
203
204 const results = yield* Effect.forEach(
205 indexFiles,
206 (file) => discoverFile(path.isAbsolute(file) ? file : path.join(cwd, file))
207 )
208 return results.filter((file) => file !== undefined)
209 })
210
211 const processFile = Effect.fn("processFile")(function*(file: BarrelFile) {
212 const { offset, pattern } = file
213 const directory = path.dirname(file.path)
214 const self = path.basename(file.path)
215
216 // Find all matching files relative to the current directory excluding the barrel file itself
217 const matchedFiles = yield* glob.glob(pattern, {
218 cwd: directory,
219 dot: false,
220 follow: false,
221 nodir: true,
222 ignore: [self]
223 }).pipe(Effect.map((files) => files.sort((a, b) => a.localeCompare(b))))
224
225 const moduleContents = yield* Effect.forEach(matchedFiles, (file) => processModule(directory, file))
226 const content = yield* fs.readFileString(file.path)
227 const lines = content.split("\n")
228 const header = lines.slice(0, offset).join("\n")
229 const generated = moduleContents.join("\n\n")
230
231 yield* fs.writeFileString(file.path, `${header}\n\n${generated}\n`)
232 })
233
234 return { discoverFiles, processFile }
235 }
236).pipe(Layer.effect(BarrelGenerator), Layer.provide(Glob.layer))