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: "unstable" | "experimental" | undefined
72}
73
74const extractModuleMetadata = (file: string, content: string): Effect.Effect<ModuleMetadata, BarrelCodegenError> => {
75 const block = content.match(/^\s*(\/\*\*[\s\S]*?\*\/)/)?.[1]
76 if (block === undefined) {
77 return Effect.fail(
78 new BarrelCodegenError({
79 path: file,
80 reason: "missing top-level module JSDoc"
81 })
82 )
83 }
84 const matches = Array.from(block.matchAll(/^\s*\*\s*@since(?:\s+(.*))?$/gm))
85 if (matches.length !== 1) {
86 return Effect.fail(
87 new BarrelCodegenError({
88 path: file,
89 reason: matches.length === 0
90 ? "missing top-level module @since tag"
91 : "multiple top-level module @since tags"
92 })
93 )
94 }
95 const since = matches[0]?.[1]?.trim() ?? ""
96 if (since.length === 0) {
97 return Effect.fail(
98 new BarrelCodegenError({
99 path: file,
100 reason: "empty top-level module @since tag"
101 })
102 )
103 }
104 return Effect.succeed({
105 since,
106 stability: isInternalModule(file, block) ?
107 undefined :
108 /^\s*\*\s*@stability\s+(unstable|experimental)\s*$/m.exec(block)?.[1] as
109 | "unstable"
110 | "experimental"
111 | undefined
112 })
113}
114
115const isInternalModule = (file: string, block: string): boolean =>
116 file.split(/[/\\]/).includes("internal") || /^\s*\*\s*@internal\s*$/m.test(block)
117
118const renderExportJSDoc = ({ since, stability }: ModuleMetadata): string =>
119 `/**
120${stability ? ` * @stability ${stability}\n` : ""} * @since ${since}
121 */`
122
123/**
124 * Metadata for a barrel file discovered from a `@barrel` annotation, including the file path, glob pattern, and insertion offset for generated exports.
125 *
126 * @category models
127 * @since 4.0.0
128 */
129export interface BarrelFile {
130 readonly path: string
131 readonly pattern: string
132 readonly offset: number
133}
134
135/**
136 * Service interface for discovering annotated barrel files and regenerating their export contents.
137 *
138 * @category services
139 * @since 4.0.0
140 */
141export interface BarrelGenerator {
142 readonly discoverFiles: (
143 pattern: string,
144 cwd: string
145 ) => Effect.Effect<Array<BarrelFile>, PlatformError | Glob.GlobError>
146 readonly processFile: (file: BarrelFile) => Effect.Effect<void, PlatformError | Glob.GlobError | BarrelCodegenError>
147}
148
149/**
150 * Service tag for barrel file generation.
151 *
152 * @category services
153 * @since 4.0.0
154 */
155export const BarrelGenerator: Context.Service<BarrelGenerator, BarrelGenerator> = Context.Service(
156 "@effect/utils/BarrelGenerator"
157)
158
159/**
160 * Builds the `BarrelGenerator` service, discovering files with `@barrel` annotations and rewriting their generated export sections from matching modules.
161 *
162 * @category layers
163 * @since 4.0.0
164 */
165export const layer: Layer.Layer<BarrelGenerator, never, FileSystem.FileSystem | Path.Path | Glob.Glob> = Effect.gen(
166 function*() {
167 const fs = yield* FileSystem.FileSystem
168 const path = yield* Path.Path
169 const glob = yield* Glob.Glob
170
171 // Convert native path separators to POSIX (forward slashes) for import/export statements
172 const toPosix = path.sep === "/" ? (file: string) => file : (file: string) => file.split(path.sep).join("/")
173
174 const fileToModuleName = (posix: string): string => {
175 const withoutExt = posix.slice(0, -path.extname(posix).length)
176 return withoutExt.replace(/\//g, "_")
177 }
178
179 const processModule = Effect.fn("processModule")(function*(directory: string, file: string) {
180 const fullPath = path.join(directory, file)
181 const posixPath = toPosix(file)
182 const content = yield* fs.readFileString(fullPath)
183 const metadata = yield* extractModuleMetadata(fullPath, content)
184 const moduleName = fileToModuleName(posixPath)
185 return `${renderExportJSDoc(metadata)}\nexport * as ${moduleName} from "./${posixPath}"`
186 })
187
188 const discoverFile = Effect.fn("discoverFile")(function*(file: string) {
189 const content = yield* fs.readFileString(file)
190 const parsed = findAnnotation(content)
191 return parsed ? { path: file, ...parsed } : undefined
192 })
193
194 const discoverFiles = Effect.fn("discoverFiles")(function*(pattern: string, cwd: string) {
195 const indexFiles = yield* glob.glob(pattern, {
196 cwd,
197 dot: false,
198 follow: false,
199 nodir: true
200 })
201
202 const results = yield* Effect.forEach(
203 indexFiles,
204 (file) => discoverFile(path.isAbsolute(file) ? file : path.join(cwd, file))
205 )
206 return results.filter((file) => file !== undefined)
207 })
208
209 const processFile = Effect.fn("processFile")(function*(file: BarrelFile) {
210 const { offset, pattern } = file
211 const directory = path.dirname(file.path)
212 const self = path.basename(file.path)
213
214 // Find all matching files relative to the current directory excluding the barrel file itself
215 const matchedFiles = yield* glob.glob(pattern, {
216 cwd: directory,
217 dot: false,
218 follow: false,
219 nodir: true,
220 ignore: [self]
221 }).pipe(Effect.map((files) => files.sort((a, b) => a.localeCompare(b))))
222
223 const moduleContents = yield* Effect.forEach(matchedFiles, (file) => processModule(directory, file))
224 const content = yield* fs.readFileString(file.path)
225 const lines = content.split("\n")
226 const header = lines.slice(0, offset).join("\n")
227 const generated = moduleContents.join("\n\n")
228
229 yield* fs.writeFileString(file.path, `${header}\n\n${generated}\n`)
230 })
231
232 return { discoverFiles, processFile }
233 }
234).pipe(Layer.effect(BarrelGenerator), Layer.provide(Glob.layer))