@canonical/summon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +439 -0
- package/generators/example/hello/index.ts +132 -0
- package/generators/example/hello/templates/README.md.ejs +20 -0
- package/generators/example/hello/templates/index.ts.ejs +9 -0
- package/generators/example/webapp/index.ts +509 -0
- package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
- package/generators/example/webapp/templates/App.tsx.ejs +86 -0
- package/generators/example/webapp/templates/README.md.ejs +154 -0
- package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
- package/generators/example/webapp/templates/app.ts.ejs +132 -0
- package/generators/example/webapp/templates/feature.ts.ejs +264 -0
- package/generators/example/webapp/templates/index.html.ejs +20 -0
- package/generators/example/webapp/templates/main.tsx.ejs +43 -0
- package/generators/example/webapp/templates/styles.css.ejs +135 -0
- package/generators/init/index.ts +124 -0
- package/generators/init/templates/generator.ts.ejs +85 -0
- package/generators/init/templates/template-index.ts.ejs +9 -0
- package/generators/init/templates/template-test.ts.ejs +8 -0
- package/package.json +64 -0
- package/src/__tests__/combinators.test.ts +895 -0
- package/src/__tests__/dry-run.test.ts +927 -0
- package/src/__tests__/effect.test.ts +816 -0
- package/src/__tests__/interpreter.test.ts +673 -0
- package/src/__tests__/primitives.test.ts +970 -0
- package/src/__tests__/task.test.ts +929 -0
- package/src/__tests__/template.test.ts +666 -0
- package/src/cli-format.ts +165 -0
- package/src/cli-types.ts +53 -0
- package/src/cli.tsx +1322 -0
- package/src/combinators.ts +294 -0
- package/src/completion.ts +488 -0
- package/src/components/App.tsx +960 -0
- package/src/components/ExecutionProgress.tsx +205 -0
- package/src/components/FileTreePreview.tsx +97 -0
- package/src/components/PromptSequence.tsx +483 -0
- package/src/components/Spinner.tsx +36 -0
- package/src/components/index.ts +16 -0
- package/src/dry-run.ts +434 -0
- package/src/effect.ts +224 -0
- package/src/index.ts +266 -0
- package/src/interpreter.ts +463 -0
- package/src/primitives.ts +442 -0
- package/src/task.ts +245 -0
- package/src/template.ts +537 -0
- package/src/types.ts +453 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitive Operations
|
|
3
|
+
*
|
|
4
|
+
* This module provides primitive task-returning functions for common operations.
|
|
5
|
+
* These are the building blocks for constructing generators.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
appendFileEffect,
|
|
10
|
+
copyDirectoryEffect,
|
|
11
|
+
copyFileEffect,
|
|
12
|
+
deleteDirectoryEffect,
|
|
13
|
+
deleteFileEffect,
|
|
14
|
+
execEffect,
|
|
15
|
+
existsEffect,
|
|
16
|
+
globEffect,
|
|
17
|
+
logEffect,
|
|
18
|
+
makeDirEffect,
|
|
19
|
+
promptEffect,
|
|
20
|
+
readContextEffect,
|
|
21
|
+
readFileEffect,
|
|
22
|
+
writeContextEffect,
|
|
23
|
+
writeFileEffect,
|
|
24
|
+
} from "./effect.js";
|
|
25
|
+
import { effect, flatMap, pure } from "./task.js";
|
|
26
|
+
import type { ExecResult, LogLevel, PromptQuestion, Task } from "./types.js";
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// File System Primitives
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a file and return its contents as a string.
|
|
34
|
+
*/
|
|
35
|
+
export const readFile = (path: string): Task<string> =>
|
|
36
|
+
effect(readFileEffect(path));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write content to a file.
|
|
40
|
+
*/
|
|
41
|
+
export const writeFile = (path: string, content: string): Task<void> =>
|
|
42
|
+
effect(writeFileEffect(path, content));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Append content to a file.
|
|
46
|
+
* @param path - Path to the file
|
|
47
|
+
* @param content - Content to append
|
|
48
|
+
* @param createIfMissing - Create the file if it doesn't exist (default: true)
|
|
49
|
+
*/
|
|
50
|
+
export const appendFile = (
|
|
51
|
+
path: string,
|
|
52
|
+
content: string,
|
|
53
|
+
createIfMissing = true,
|
|
54
|
+
): Task<void> => effect(appendFileEffect(path, content, createIfMissing));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copy a file from source to destination.
|
|
58
|
+
*/
|
|
59
|
+
export const copyFile = (source: string, dest: string): Task<void> =>
|
|
60
|
+
effect(copyFileEffect(source, dest));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Copy a directory recursively from source to destination.
|
|
64
|
+
*/
|
|
65
|
+
export const copyDirectory = (source: string, dest: string): Task<void> =>
|
|
66
|
+
effect(copyDirectoryEffect(source, dest));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Delete a file.
|
|
70
|
+
*/
|
|
71
|
+
export const deleteFile = (path: string): Task<void> =>
|
|
72
|
+
effect(deleteFileEffect(path));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete a directory recursively.
|
|
76
|
+
*/
|
|
77
|
+
export const deleteDirectory = (path: string): Task<void> =>
|
|
78
|
+
effect(deleteDirectoryEffect(path));
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a directory (recursively by default).
|
|
82
|
+
*/
|
|
83
|
+
export const mkdir = (path: string, recursive = true): Task<void> =>
|
|
84
|
+
effect(makeDirEffect(path, recursive));
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a file or directory exists.
|
|
88
|
+
*/
|
|
89
|
+
export const exists = (path: string): Task<boolean> =>
|
|
90
|
+
effect(existsEffect(path));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Find files matching a glob pattern.
|
|
94
|
+
*/
|
|
95
|
+
export const glob = (pattern: string, cwd: string): Task<string[]> =>
|
|
96
|
+
effect(globEffect(pattern, cwd));
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// File Transformation Primitives
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Options for sorting file lines.
|
|
104
|
+
*/
|
|
105
|
+
export interface SortFileLinesOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Custom comparator function for sorting.
|
|
108
|
+
* Defaults to locale-aware string comparison.
|
|
109
|
+
*/
|
|
110
|
+
compare?: (a: string, b: string) => number;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* If true, remove duplicate lines after sorting.
|
|
114
|
+
* @default false
|
|
115
|
+
*/
|
|
116
|
+
unique?: boolean;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Lines matching this pattern are considered "header" lines and will
|
|
120
|
+
* be kept at the top of the file, unsorted.
|
|
121
|
+
* Useful for preserving file headers, comments, or specific imports.
|
|
122
|
+
*
|
|
123
|
+
* @example /^\/\// - Keep lines starting with // at the top
|
|
124
|
+
* @example /^import.*from ["']react["']/ - Keep React imports at top
|
|
125
|
+
*/
|
|
126
|
+
headerPattern?: RegExp;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Lines matching this pattern are considered "footer" lines and will
|
|
130
|
+
* be kept at the bottom of the file, unsorted.
|
|
131
|
+
*/
|
|
132
|
+
footerPattern?: RegExp;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* If true, preserve blank lines in their relative positions within
|
|
136
|
+
* the sorted content. If false, blank lines are sorted with other lines.
|
|
137
|
+
* @default false
|
|
138
|
+
*/
|
|
139
|
+
preserveBlankLines?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sort the lines of a file.
|
|
144
|
+
*
|
|
145
|
+
* This is useful for maintaining sorted barrel files (index.ts with exports),
|
|
146
|
+
* sorted import lists, or any file where line order should be alphabetical.
|
|
147
|
+
*
|
|
148
|
+
* @param path - Path to the file to sort
|
|
149
|
+
* @param options - Sorting options
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* // Sort a barrel file alphabetically
|
|
153
|
+
* sortFileLines("src/index.ts")
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* // Sort exports, keeping the header comment
|
|
157
|
+
* sortFileLines("src/index.ts", {
|
|
158
|
+
* headerPattern: /^\/\//, // Keep comment lines at top
|
|
159
|
+
* })
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* // Sort and deduplicate
|
|
163
|
+
* sortFileLines("src/exports.ts", { unique: true })
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* // Custom sort (case-insensitive)
|
|
167
|
+
* sortFileLines("src/index.ts", {
|
|
168
|
+
* compare: (a, b) => a.toLowerCase().localeCompare(b.toLowerCase())
|
|
169
|
+
* })
|
|
170
|
+
*/
|
|
171
|
+
export const sortFileLines = (
|
|
172
|
+
path: string,
|
|
173
|
+
options: SortFileLinesOptions = {},
|
|
174
|
+
): Task<void> => {
|
|
175
|
+
const {
|
|
176
|
+
compare = (a, b) => a.localeCompare(b),
|
|
177
|
+
unique = false,
|
|
178
|
+
headerPattern,
|
|
179
|
+
footerPattern,
|
|
180
|
+
preserveBlankLines = false,
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
return flatMap(readFile(path), (content) => {
|
|
184
|
+
const lines = content.split("\n");
|
|
185
|
+
|
|
186
|
+
// Separate header, body, and footer lines
|
|
187
|
+
const headerLines: string[] = [];
|
|
188
|
+
const footerLines: string[] = [];
|
|
189
|
+
const bodyLines: string[] = [];
|
|
190
|
+
const blankLineIndices: number[] = [];
|
|
191
|
+
|
|
192
|
+
// First pass: identify header lines (consecutive matches at start)
|
|
193
|
+
let inHeader = true;
|
|
194
|
+
let bodyStartIndex = 0;
|
|
195
|
+
|
|
196
|
+
if (headerPattern) {
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
if (inHeader && headerPattern.test(lines[i])) {
|
|
199
|
+
headerLines.push(lines[i]);
|
|
200
|
+
bodyStartIndex = i + 1;
|
|
201
|
+
} else {
|
|
202
|
+
inHeader = false;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Second pass: identify footer lines (consecutive matches at end)
|
|
209
|
+
let footerStartIndex = lines.length;
|
|
210
|
+
|
|
211
|
+
if (footerPattern) {
|
|
212
|
+
for (let i = lines.length - 1; i >= bodyStartIndex; i--) {
|
|
213
|
+
if (footerPattern.test(lines[i])) {
|
|
214
|
+
footerLines.unshift(lines[i]);
|
|
215
|
+
footerStartIndex = i;
|
|
216
|
+
} else {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Third pass: collect body lines
|
|
223
|
+
for (let i = bodyStartIndex; i < footerStartIndex; i++) {
|
|
224
|
+
const line = lines[i];
|
|
225
|
+
if (preserveBlankLines && line.trim() === "") {
|
|
226
|
+
blankLineIndices.push(bodyLines.length);
|
|
227
|
+
bodyLines.push(line);
|
|
228
|
+
} else {
|
|
229
|
+
bodyLines.push(line);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Sort body lines (excluding blank lines if preserving them)
|
|
234
|
+
let sortedBody: string[];
|
|
235
|
+
|
|
236
|
+
if (preserveBlankLines) {
|
|
237
|
+
// Extract non-blank lines, sort them, then reinsert blanks
|
|
238
|
+
const nonBlankLines = bodyLines.filter((line) => line.trim() !== "");
|
|
239
|
+
const sortedNonBlank = unique
|
|
240
|
+
? [...new Set(nonBlankLines)].sort(compare)
|
|
241
|
+
: nonBlankLines.sort(compare);
|
|
242
|
+
|
|
243
|
+
// Rebuild with blank lines in relative positions
|
|
244
|
+
sortedBody = [];
|
|
245
|
+
let nonBlankIdx = 0;
|
|
246
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
247
|
+
if (blankLineIndices.includes(i)) {
|
|
248
|
+
sortedBody.push("");
|
|
249
|
+
} else if (nonBlankIdx < sortedNonBlank.length) {
|
|
250
|
+
sortedBody.push(sortedNonBlank[nonBlankIdx++]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
sortedBody = unique
|
|
255
|
+
? [...new Set(bodyLines)].sort(compare)
|
|
256
|
+
: bodyLines.sort(compare);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Reassemble the file
|
|
260
|
+
const sortedContent = [...headerLines, ...sortedBody, ...footerLines].join(
|
|
261
|
+
"\n",
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return writeFile(path, sortedContent);
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// =============================================================================
|
|
269
|
+
// Process Primitives
|
|
270
|
+
// =============================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Execute a command with arguments.
|
|
274
|
+
*/
|
|
275
|
+
export const exec = (
|
|
276
|
+
command: string,
|
|
277
|
+
args: string[],
|
|
278
|
+
cwd?: string,
|
|
279
|
+
): Task<ExecResult> => effect(execEffect(command, args, cwd));
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Execute a simple command string (split on spaces).
|
|
283
|
+
*/
|
|
284
|
+
export const execSimple = (
|
|
285
|
+
commandLine: string,
|
|
286
|
+
cwd?: string,
|
|
287
|
+
): Task<ExecResult> => {
|
|
288
|
+
const parts = commandLine.split(" ");
|
|
289
|
+
const [command, ...args] = parts;
|
|
290
|
+
return exec(command, args, cwd);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// =============================================================================
|
|
294
|
+
// Prompt Primitives
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Prompt the user with a question.
|
|
299
|
+
*/
|
|
300
|
+
export const prompt = <T = unknown>(question: PromptQuestion): Task<T> =>
|
|
301
|
+
effect(promptEffect(question));
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Prompt for text input.
|
|
305
|
+
*/
|
|
306
|
+
export const promptText = (
|
|
307
|
+
name: string,
|
|
308
|
+
message: string,
|
|
309
|
+
defaultValue?: string,
|
|
310
|
+
): Task<string> =>
|
|
311
|
+
prompt({
|
|
312
|
+
type: "text",
|
|
313
|
+
name,
|
|
314
|
+
message,
|
|
315
|
+
default: defaultValue,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Prompt for confirmation.
|
|
320
|
+
*/
|
|
321
|
+
export const promptConfirm = (
|
|
322
|
+
name: string,
|
|
323
|
+
message: string,
|
|
324
|
+
defaultValue = false,
|
|
325
|
+
): Task<boolean> =>
|
|
326
|
+
prompt({
|
|
327
|
+
type: "confirm",
|
|
328
|
+
name,
|
|
329
|
+
message,
|
|
330
|
+
default: defaultValue,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Prompt for selection from a list.
|
|
335
|
+
*/
|
|
336
|
+
export const promptSelect = (
|
|
337
|
+
name: string,
|
|
338
|
+
message: string,
|
|
339
|
+
choices: Array<{ label: string; value: string }>,
|
|
340
|
+
defaultValue?: string,
|
|
341
|
+
): Task<string> =>
|
|
342
|
+
prompt({
|
|
343
|
+
type: "select",
|
|
344
|
+
name,
|
|
345
|
+
message,
|
|
346
|
+
choices,
|
|
347
|
+
default: defaultValue,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Prompt for multiple selections from a list.
|
|
352
|
+
*/
|
|
353
|
+
export const promptMultiselect = (
|
|
354
|
+
name: string,
|
|
355
|
+
message: string,
|
|
356
|
+
choices: Array<{ label: string; value: string }>,
|
|
357
|
+
defaultValue?: string[],
|
|
358
|
+
): Task<string[]> =>
|
|
359
|
+
prompt({
|
|
360
|
+
type: "multiselect",
|
|
361
|
+
name,
|
|
362
|
+
message,
|
|
363
|
+
choices,
|
|
364
|
+
default: defaultValue,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// =============================================================================
|
|
368
|
+
// Logging Primitives
|
|
369
|
+
// =============================================================================
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Log a message at a specific level.
|
|
373
|
+
*/
|
|
374
|
+
export const log = (level: LogLevel, message: string): Task<void> =>
|
|
375
|
+
effect(logEffect(level, message));
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Log a debug message.
|
|
379
|
+
*/
|
|
380
|
+
export const debug = (message: string): Task<void> => log("debug", message);
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Log an info message.
|
|
384
|
+
*/
|
|
385
|
+
export const info = (message: string): Task<void> => log("info", message);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Log a warning message.
|
|
389
|
+
*/
|
|
390
|
+
export const warn = (message: string): Task<void> => log("warn", message);
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Log an error message.
|
|
394
|
+
*/
|
|
395
|
+
export const error = (message: string): Task<void> => log("error", message);
|
|
396
|
+
|
|
397
|
+
// =============================================================================
|
|
398
|
+
// Context Primitives
|
|
399
|
+
// =============================================================================
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Read a value from the context.
|
|
403
|
+
*/
|
|
404
|
+
export const getContext = <T = unknown>(key: string): Task<T | undefined> =>
|
|
405
|
+
effect(readContextEffect(key));
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Write a value to the context.
|
|
409
|
+
*/
|
|
410
|
+
export const setContext = (key: string, value: unknown): Task<void> =>
|
|
411
|
+
effect(writeContextEffect(key, value));
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Execute a task with a temporary context value.
|
|
415
|
+
*/
|
|
416
|
+
export const withContext = <A>(
|
|
417
|
+
key: string,
|
|
418
|
+
value: unknown,
|
|
419
|
+
task: Task<A>,
|
|
420
|
+
): Task<A> => {
|
|
421
|
+
// This is a higher-level pattern that will be handled specially by interpreters
|
|
422
|
+
// For now, we implement it as set -> run -> restore
|
|
423
|
+
return {
|
|
424
|
+
_tag: "Effect",
|
|
425
|
+
effect: writeContextEffect(key, value),
|
|
426
|
+
cont: () => task,
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// =============================================================================
|
|
431
|
+
// Pure Primitives
|
|
432
|
+
// =============================================================================
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A task that does nothing and returns void.
|
|
436
|
+
*/
|
|
437
|
+
export const noop: Task<void> = pure(undefined);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A task that returns the given value.
|
|
441
|
+
*/
|
|
442
|
+
export const succeed = <A>(value: A): Task<A> => pure(value);
|
package/src/task.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Monad Implementation
|
|
3
|
+
*
|
|
4
|
+
* The Task monad is the core abstraction for building composable, testable generators.
|
|
5
|
+
* Tasks represent computations that may perform effects and can fail.
|
|
6
|
+
*
|
|
7
|
+
* Key properties:
|
|
8
|
+
* - Pure: Tasks return effect descriptions, not actual effects
|
|
9
|
+
* - Composable: Tasks can be sequenced with flatMap/chain
|
|
10
|
+
* - Testable: Effects can be collected without execution
|
|
11
|
+
* - Type-safe: Full TypeScript inference for composed tasks
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Effect, Task, TaskError } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Core Constructors
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lift a pure value into a Task.
|
|
22
|
+
*/
|
|
23
|
+
export const pure = <A>(value: A): Task<A> => ({
|
|
24
|
+
_tag: "Pure",
|
|
25
|
+
value,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a Task from an Effect.
|
|
30
|
+
* The continuation receives the result of executing the effect.
|
|
31
|
+
*/
|
|
32
|
+
export const effect = <A>(eff: Effect): Task<A> => ({
|
|
33
|
+
_tag: "Effect",
|
|
34
|
+
effect: eff,
|
|
35
|
+
cont: (result) => pure(result as A),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a failed Task with an error.
|
|
40
|
+
*/
|
|
41
|
+
export const fail = <A = never>(error: TaskError): Task<A> => ({
|
|
42
|
+
_tag: "Fail",
|
|
43
|
+
error,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a failed Task from an error code and message.
|
|
48
|
+
*/
|
|
49
|
+
export const failWith = <A = never>(code: string, message: string): Task<A> =>
|
|
50
|
+
fail({
|
|
51
|
+
code,
|
|
52
|
+
message,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Monad Operations
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Monadic bind (flatMap).
|
|
61
|
+
* Sequences two tasks, passing the result of the first to produce the second.
|
|
62
|
+
*/
|
|
63
|
+
export const flatMap = <A, B>(task: Task<A>, f: (a: A) => Task<B>): Task<B> => {
|
|
64
|
+
switch (task._tag) {
|
|
65
|
+
case "Pure":
|
|
66
|
+
return f(task.value);
|
|
67
|
+
case "Fail":
|
|
68
|
+
return task as unknown as Task<B>;
|
|
69
|
+
case "Effect":
|
|
70
|
+
return {
|
|
71
|
+
_tag: "Effect",
|
|
72
|
+
effect: task.effect,
|
|
73
|
+
cont: (result) => flatMap(task.cont(result), f),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Functor map.
|
|
80
|
+
* Transform the result of a task with a pure function.
|
|
81
|
+
*/
|
|
82
|
+
export const map = <A, B>(task: Task<A>, f: (a: A) => B): Task<B> =>
|
|
83
|
+
flatMap(task, (a) => pure(f(a)));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply a function wrapped in a Task to a value wrapped in a Task.
|
|
87
|
+
*/
|
|
88
|
+
export const ap = <A, B>(taskF: Task<(a: A) => B>, taskA: Task<A>): Task<B> =>
|
|
89
|
+
flatMap(taskF, (f) => map(taskA, f));
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Error recovery.
|
|
93
|
+
* If the task fails, the handler can produce a new task.
|
|
94
|
+
*/
|
|
95
|
+
export const recover = <A>(
|
|
96
|
+
task: Task<A>,
|
|
97
|
+
handler: (error: TaskError) => Task<A>,
|
|
98
|
+
): Task<A> => {
|
|
99
|
+
switch (task._tag) {
|
|
100
|
+
case "Pure":
|
|
101
|
+
return task;
|
|
102
|
+
case "Fail":
|
|
103
|
+
return handler(task.error);
|
|
104
|
+
case "Effect":
|
|
105
|
+
return {
|
|
106
|
+
_tag: "Effect",
|
|
107
|
+
effect: task.effect,
|
|
108
|
+
cont: (result) => recover(task.cont(result), handler),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map over the error of a failed task.
|
|
115
|
+
*/
|
|
116
|
+
export const mapError = <A>(
|
|
117
|
+
task: Task<A>,
|
|
118
|
+
f: (error: TaskError) => TaskError,
|
|
119
|
+
): Task<A> => {
|
|
120
|
+
switch (task._tag) {
|
|
121
|
+
case "Pure":
|
|
122
|
+
return task;
|
|
123
|
+
case "Fail":
|
|
124
|
+
return fail(f(task.error));
|
|
125
|
+
case "Effect":
|
|
126
|
+
return {
|
|
127
|
+
_tag: "Effect",
|
|
128
|
+
effect: task.effect,
|
|
129
|
+
cont: (result) => mapError(task.cont(result), f),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// Fluent Builder API
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fluent builder for composing tasks.
|
|
140
|
+
* Provides a chainable API for common task operations.
|
|
141
|
+
*/
|
|
142
|
+
export class TaskBuilder<A> {
|
|
143
|
+
constructor(private readonly _task: Task<A>) {}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Transform the result with a pure function.
|
|
147
|
+
*/
|
|
148
|
+
map<B>(f: (a: A) => B): TaskBuilder<B> {
|
|
149
|
+
return new TaskBuilder(map(this._task, f));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Chain with another task-producing function.
|
|
154
|
+
*/
|
|
155
|
+
flatMap<B>(f: (a: A) => Task<B>): TaskBuilder<B> {
|
|
156
|
+
return new TaskBuilder(flatMap(this._task, f));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Chain with another TaskBuilder-producing function.
|
|
161
|
+
*/
|
|
162
|
+
chain<B>(f: (a: A) => TaskBuilder<B>): TaskBuilder<B> {
|
|
163
|
+
return new TaskBuilder(flatMap(this._task, (a) => f(a).unwrap()));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Recover from errors.
|
|
168
|
+
*/
|
|
169
|
+
recover(f: (error: TaskError) => Task<A>): TaskBuilder<A> {
|
|
170
|
+
return new TaskBuilder(recover(this._task, f));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Map over errors.
|
|
175
|
+
*/
|
|
176
|
+
mapError(f: (error: TaskError) => TaskError): TaskBuilder<A> {
|
|
177
|
+
return new TaskBuilder(mapError(this._task, f));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Execute a side effect without changing the value.
|
|
182
|
+
*/
|
|
183
|
+
tap(f: (a: A) => Task<unknown>): TaskBuilder<A> {
|
|
184
|
+
return new TaskBuilder(flatMap(this._task, (a) => map(f(a), () => a)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sequence with another task, discarding the current value.
|
|
189
|
+
* Useful for chaining void tasks.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* task(mkdir("output"))
|
|
193
|
+
* .andThen(writeFile("output/a.txt", "A"))
|
|
194
|
+
* .andThen(writeFile("output/b.txt", "B"))
|
|
195
|
+
* .andThen(info("Done!"))
|
|
196
|
+
*/
|
|
197
|
+
andThen<B>(next: Task<B>): TaskBuilder<B> {
|
|
198
|
+
return new TaskBuilder(flatMap(this._task, () => next));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract the underlying Task.
|
|
203
|
+
*/
|
|
204
|
+
unwrap(): Task<A> {
|
|
205
|
+
return this._task;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create a TaskBuilder from a Task.
|
|
211
|
+
*/
|
|
212
|
+
export const task = <A>(t: Task<A>): TaskBuilder<A> => new TaskBuilder(t);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a TaskBuilder from a pure value.
|
|
216
|
+
*/
|
|
217
|
+
export const of = <A>(value: A): TaskBuilder<A> => task(pure(value));
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Utility Functions
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a task is pure (no effects).
|
|
225
|
+
*/
|
|
226
|
+
export const isPure = <A>(t: Task<A>): t is { _tag: "Pure"; value: A } =>
|
|
227
|
+
t._tag === "Pure";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if a task has failed.
|
|
231
|
+
*/
|
|
232
|
+
export const isFailed = <A>(
|
|
233
|
+
t: Task<A>,
|
|
234
|
+
): t is { _tag: "Fail"; error: TaskError } => t._tag === "Fail";
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check if a task has effects.
|
|
238
|
+
*/
|
|
239
|
+
export const hasEffects = <A>(
|
|
240
|
+
t: Task<A>,
|
|
241
|
+
): t is {
|
|
242
|
+
_tag: "Effect";
|
|
243
|
+
effect: Effect;
|
|
244
|
+
cont: (result: unknown) => Task<A>;
|
|
245
|
+
} => t._tag === "Effect";
|