Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
4
repos/effect/packages/tools/utils/docgen.json
Normal file
4
repos/effect/packages/tools/utils/docgen.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/@effect/docgen/schema.json",
|
||||
"exclude": ["**/*.ts"]
|
||||
}
|
||||
58
repos/effect/packages/tools/utils/package.json
Normal file
58
repos/effect/packages/tools/utils/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@effect/utils",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Build tools for the repositories following the Effect standard setup",
|
||||
"homepage": "https://effect.website",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Effect-TS/effect.git",
|
||||
"directory": "packages/tools/utils"
|
||||
},
|
||||
"sideEffects": [],
|
||||
"bin": {
|
||||
"effect-utils": "./src/bin.ts"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./*": "./src/*.ts",
|
||||
"./commands/*": null,
|
||||
"./bin": null
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.js.map",
|
||||
"dist/**/*.d.ts",
|
||||
"dist/**/*.d.ts.map"
|
||||
],
|
||||
"publishConfig": {
|
||||
"provenance": true,
|
||||
"bin": {
|
||||
"effect-utils": "./dist/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./*": "./dist/*.js",
|
||||
"./commands/*": null,
|
||||
"./bin": null
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b tsconfig.json && pnpm babel",
|
||||
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
|
||||
"check": "tsc -b tsconfig.json",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "workspace:^",
|
||||
"effect": "workspace:^",
|
||||
"glob": "^13.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1"
|
||||
}
|
||||
}
|
||||
214
repos/effect/packages/tools/utils/src/Codegen.ts
Normal file
214
repos/effect/packages/tools/utils/src/Codegen.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Barrel file discovery and export generation for Effect development tools.
|
||||
*
|
||||
* This module provides the `BarrelGenerator` service used by the codegen CLI to
|
||||
* find files annotated with `@barrel` comments and rewrite the generated export
|
||||
* section beneath each annotation. The generator resolves matching modules
|
||||
* relative to each annotated barrel file, copies each module's top-level
|
||||
* `@since` tag into a minimal JSDoc block, and normalizes export paths so the
|
||||
* produced TypeScript is stable across platforms.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import * as Context from "effect/Context"
|
||||
import * as Data from "effect/Data"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import * as Glob from "./Glob.ts"
|
||||
|
||||
/**
|
||||
* Error raised when barrel export generation cannot read required module metadata.
|
||||
*
|
||||
* @category errors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export class BarrelCodegenError extends Data.TaggedError("BarrelCodegenError")<{
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}> {}
|
||||
|
||||
const findAnnotation = (content: string): { pattern: string; offset: number } | undefined => {
|
||||
const lines = content.split("\n")
|
||||
|
||||
// Find the line containing the annotation
|
||||
let annotationLine = -1
|
||||
let pattern: string | undefined
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
pattern = parseAnnotation(lines[i])
|
||||
if (pattern !== undefined) {
|
||||
annotationLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (annotationLine === -1 || pattern === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Walk forwards to find the end of the comment block
|
||||
let commentEnd = annotationLine
|
||||
while (commentEnd < lines.length - 1 && lines[commentEnd + 1].trimStart().startsWith("//")) {
|
||||
commentEnd++
|
||||
}
|
||||
|
||||
return { pattern, offset: commentEnd + 1 }
|
||||
}
|
||||
|
||||
const parseAnnotation = (line: string): string | undefined => {
|
||||
const match = line.match(/^\/\/\s*@barrel(?:\((.+?)\))?/)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
return match[1] ?? "*.ts"
|
||||
}
|
||||
|
||||
const extractModuleSince = (file: string, content: string): Effect.Effect<string, BarrelCodegenError> => {
|
||||
const block = content.match(/^\s*(\/\*\*[\s\S]*?\*\/)/)?.[1]
|
||||
if (block === undefined) {
|
||||
return Effect.fail(
|
||||
new BarrelCodegenError({
|
||||
path: file,
|
||||
reason: "missing top-level module JSDoc"
|
||||
})
|
||||
)
|
||||
}
|
||||
const matches = Array.from(block.matchAll(/^\s*\*\s*@since(?:\s+(.*))?$/gm))
|
||||
if (matches.length !== 1) {
|
||||
return Effect.fail(
|
||||
new BarrelCodegenError({
|
||||
path: file,
|
||||
reason: matches.length === 0
|
||||
? "missing top-level module @since tag"
|
||||
: "multiple top-level module @since tags"
|
||||
})
|
||||
)
|
||||
}
|
||||
const since = matches[0]?.[1]?.trim() ?? ""
|
||||
if (since.length === 0) {
|
||||
return Effect.fail(
|
||||
new BarrelCodegenError({
|
||||
path: file,
|
||||
reason: "empty top-level module @since tag"
|
||||
})
|
||||
)
|
||||
}
|
||||
return Effect.succeed(since)
|
||||
}
|
||||
|
||||
const renderExportJSDoc = (since: string): string =>
|
||||
`/**
|
||||
* @since ${since}
|
||||
*/`
|
||||
|
||||
/**
|
||||
* Metadata for a barrel file discovered from a `@barrel` annotation, including the file path, glob pattern, and insertion offset for generated exports.
|
||||
*
|
||||
* @category models
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface BarrelFile {
|
||||
readonly path: string
|
||||
readonly pattern: string
|
||||
readonly offset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Service interface for discovering annotated barrel files and regenerating their export contents.
|
||||
*
|
||||
* @category models
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface BarrelGenerator {
|
||||
readonly discoverFiles: (
|
||||
pattern: string,
|
||||
cwd: string
|
||||
) => Effect.Effect<Array<BarrelFile>, PlatformError | Glob.GlobError>
|
||||
readonly processFile: (file: BarrelFile) => Effect.Effect<void, PlatformError | Glob.GlobError | BarrelCodegenError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Service tag for barrel file generation.
|
||||
*
|
||||
* @category services
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const BarrelGenerator: Context.Service<BarrelGenerator, BarrelGenerator> = Context.Service(
|
||||
"@effect/utils/BarrelGenerator"
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the `BarrelGenerator` service, discovering files with `@barrel` annotations and rewriting their generated export sections from matching modules.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layer: Layer.Layer<BarrelGenerator, never, FileSystem.FileSystem | Path.Path | Glob.Glob> = Effect.gen(
|
||||
function*() {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const glob = yield* Glob.Glob
|
||||
|
||||
// Convert native path separators to POSIX (forward slashes) for import/export statements
|
||||
const toPosix = path.sep === "/" ? (file: string) => file : (file: string) => file.split(path.sep).join("/")
|
||||
|
||||
const fileToModuleName = (posix: string): string => {
|
||||
const withoutExt = posix.slice(0, -path.extname(posix).length)
|
||||
return withoutExt.replace(/\//g, "_")
|
||||
}
|
||||
|
||||
const processModule = Effect.fn("processModule")(function*(directory: string, file: string) {
|
||||
const fullPath = path.join(directory, file)
|
||||
const posixPath = toPosix(file)
|
||||
const content = yield* fs.readFileString(fullPath)
|
||||
const since = yield* extractModuleSince(fullPath, content)
|
||||
const moduleName = fileToModuleName(posixPath)
|
||||
return `${renderExportJSDoc(since)}\nexport * as ${moduleName} from "./${posixPath}"`
|
||||
})
|
||||
|
||||
const discoverFile = Effect.fn("discoverFile")(function*(file: string) {
|
||||
const content = yield* fs.readFileString(file)
|
||||
const parsed = findAnnotation(content)
|
||||
return parsed ? { path: file, ...parsed } : undefined
|
||||
})
|
||||
|
||||
const discoverFiles = Effect.fn("discoverFiles")(function*(pattern: string, cwd: string) {
|
||||
const indexFiles = yield* glob.glob(pattern, {
|
||||
cwd,
|
||||
dot: false,
|
||||
follow: false,
|
||||
nodir: true
|
||||
})
|
||||
|
||||
const results = yield* Effect.forEach(indexFiles, (file) => discoverFile(path.join(cwd, file)))
|
||||
return results.filter((file) => file !== undefined)
|
||||
})
|
||||
|
||||
const processFile = Effect.fn("processFile")(function*(file: BarrelFile) {
|
||||
const { offset, pattern } = file
|
||||
const directory = path.dirname(file.path)
|
||||
const self = path.basename(file.path)
|
||||
|
||||
// Find all matching files relative to the current directory excluding the barrel file itself
|
||||
const matchedFiles = yield* glob.glob(pattern, {
|
||||
cwd: directory,
|
||||
dot: false,
|
||||
follow: false,
|
||||
nodir: true,
|
||||
ignore: [self]
|
||||
}).pipe(Effect.map((files) => files.sort((a, b) => a.localeCompare(b))))
|
||||
|
||||
const moduleContents = yield* Effect.forEach(matchedFiles, (file) => processModule(directory, file))
|
||||
const content = yield* fs.readFileString(file.path)
|
||||
const lines = content.split("\n")
|
||||
const header = lines.slice(0, offset).join("\n")
|
||||
const generated = moduleContents.join("\n\n")
|
||||
|
||||
yield* fs.writeFileString(file.path, `${header}\n\n${generated}\n`)
|
||||
})
|
||||
|
||||
return { discoverFiles, processFile }
|
||||
}
|
||||
).pipe(Layer.effect(BarrelGenerator), Layer.provide(Glob.layer))
|
||||
61
repos/effect/packages/tools/utils/src/Glob.ts
Normal file
61
repos/effect/packages/tools/utils/src/Glob.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Provides the glob service used by tooling commands to discover files from
|
||||
* glob patterns while keeping filesystem matching inside Effect.
|
||||
*
|
||||
* The service wraps the `glob` package and converts matching failures into
|
||||
* `GlobError` values so command implementations can compose file discovery
|
||||
* with other typed Effect workflows.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import * as Context from "effect/Context"
|
||||
import * as Data from "effect/Data"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as GlobLib from "glob"
|
||||
|
||||
/**
|
||||
* Error raised when glob pattern matching fails.
|
||||
*
|
||||
* @category errors
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export class GlobError extends Data.TaggedError("GlobError")<{
|
||||
readonly pattern: string | ReadonlyArray<string>
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* Service interface for matching filesystem paths with glob patterns.
|
||||
*
|
||||
* @category models
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface Glob {
|
||||
readonly glob: (
|
||||
pattern: string | ReadonlyArray<string>,
|
||||
options?: GlobLib.GlobOptions
|
||||
) => Effect.Effect<Array<string>, GlobError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Service tag for filesystem glob pattern matching.
|
||||
*
|
||||
* @category services
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const Glob: Context.Service<Glob, Glob> = Context.Service("@effect/utils/Glob")
|
||||
|
||||
/**
|
||||
* Layer that provides the `Glob` service using the `glob` package and maps matching failures to `GlobError`.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layer: Layer.Layer<Glob> = Layer.succeed(Glob, {
|
||||
glob: (pattern, options) =>
|
||||
Effect.tryPromise({
|
||||
try: () => GlobLib.glob(pattern as string | Array<string>, options ?? {}) as Promise<Array<string>>,
|
||||
catch: (cause) => new GlobError({ pattern, cause })
|
||||
})
|
||||
})
|
||||
16
repos/effect/packages/tools/utils/src/bin.ts
Executable file
16
repos/effect/packages/tools/utils/src/bin.ts
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { codegen } from "./commands/codegen.ts"
|
||||
|
||||
const cli = Command.make("effect-utils").pipe(
|
||||
Command.withSubcommands([codegen])
|
||||
)
|
||||
|
||||
const main = Command.run(cli, { version: "0.0.0" }).pipe(
|
||||
Effect.provide(NodeServices.layer)
|
||||
)
|
||||
|
||||
Effect.runPromise(main)
|
||||
44
repos/effect/packages/tools/utils/src/commands/codegen.ts
Normal file
44
repos/effect/packages/tools/utils/src/commands/codegen.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Defines the `codegen` CLI command for regenerating annotated barrel files in
|
||||
* the utilities package. The command wires together the code generation and
|
||||
* globbing services, resolves the requested working directory, discovers
|
||||
* `@barrel` annotations, and rewrites matching barrel exports.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import * as Flag from "effect/unstable/cli/Flag"
|
||||
import * as Codegen from "../Codegen.ts"
|
||||
import * as Glob from "../Glob.ts"
|
||||
|
||||
const CodegenLayer = Layer.provideMerge(Codegen.layer, Glob.layer)
|
||||
|
||||
/**
|
||||
* CLI command that regenerates annotated barrel files.
|
||||
*
|
||||
* **Details**
|
||||
*
|
||||
* The command scans `cwd` for files matching `pattern`, finds `@barrel`
|
||||
* annotations, and rewrites each annotated barrel with generated exports from
|
||||
* matching modules.
|
||||
*
|
||||
* @category commands
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const codegen = Command.make("codegen", {
|
||||
cwd: Flag.directory("cwd", { mustExist: true }).pipe(Flag.withDefault(".")),
|
||||
pattern: Flag.string("pattern").pipe(Flag.withDefault("src/**/index.ts"))
|
||||
}, (config) =>
|
||||
Effect.gen(function*() {
|
||||
const path = yield* Path.Path
|
||||
const generator = yield* Codegen.BarrelGenerator
|
||||
const files = yield* generator.discoverFiles(config.pattern, path.resolve(config.cwd))
|
||||
|
||||
yield* Effect.forEach(files, (file) => generator.processFile(file), {
|
||||
concurrency: "inherit",
|
||||
discard: true
|
||||
})
|
||||
})).pipe(Command.provide(CodegenLayer))
|
||||
9
repos/effect/packages/tools/utils/tsconfig.json
Normal file
9
repos/effect/packages/tools/utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../effect" },
|
||||
{ "path": "../../platform-node" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user