diff --git a/README.md b/README.md index ca80cb0..80a8d44 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ These changes improve throughput and reduce memory pressure when working with la - [stringPermutations](#stringpermutations) - Generates all unique permutations of a given string. - [stringPermutationsGenerator](#stringpermutationsgenerator) - Generator-based permutations API for lazy iteration. - [stringCombinations](#stringcombinations) - Generates all unique combinations of a given string. +- [pipeLine](#pipeline) - Can be used to chain multiple transformations, and powerful tools to manipulate data flow ### Validations @@ -473,6 +474,146 @@ deburr('über cool'); | --------- | ------ | -------- | ----------------------------------------- | | text | string | required | The input string to strip diacritics from | +#### `pipeLine(args)` +Apply multiple transformations in any order and add effects that detect +changes during transformation process. + +```typescript +pipeLine({ + initial: "Hello World!", + pipes: [ camelCase, capitalize ] +}).output; // Helloworld +``` +Optionally, an effect callback can be added to have more effects in-between transformations. + +```typescript +pipeLine({ + initial: "Evil Code Exceeding So Much Space!", + pipe: [camelCase, removeDuplicates], + effect({ target, restartBeforeTransform }) { + + if (target.length > 10) { + //evil code + restartBeforeTransform(target.slice(0, -1)) + } + + } +}).output // evilcodeex +``` + +#### `pipeLine` Parameters + +| Parameter | Type | Default | Description | +|------------|----------------------------------------|-----------|-----------------------------------------------------------------------------| +| `initial` | `string` | required | The initial input string to be processed. | +| `pipe` | `Array<(data: string) => string>` | required | Transformation functions applied sequentially to the input string. | +| `effect` | `(args: EffectArgs) => any` | optional | Optional effect function with access to transformation context and control. | + +#### `effect` Function Context (`EffectArgs`) + +| Property | Type | Description | +|------------------------|---------------------------------------------------|-----------------------------------------------------------------------------| +| `history` | `Array<{ operation: string, value: string }>` | A log of each transformation step and its result. | +| `first` | `string` | The original string before any transformations. | +| `target` | `string` | The final string after all transformations are complete. | +| `pipe` | `Array<(data: string) => string>` | The original array of transformation functions. | +| `abort` | `() => any` | Stops pipeline execution immediately. | +| `forceStop` | `(last: string) => any` | Halts processing and returns the current result. | +| `next` | `(string: string) => any` | Continues pipeline execution from the next transformation. | +| `restartBeforeTransform` | `(string?: string) => any` | Restarts the pipeline before calling the effect; optionally with new input.| +| `restartAfterTransform` | `(string?: string) => any` | Restarts the pipeline after calling the effect; optionally with new input. | +| `moveToPipeIndex` | `(index: number) => any` | Jumps to a specific transformation index to resume processing. | + +#### `pipe(initial)` + +Lightweight and framework-agnostic string transformer with optional effect hooks and full pipeline control. Chain `.flow()` to get output or `.raw()` for transformation metadata. + +```ts +pipe("Evil Code Exceeding So Much Space!") + .effect(({ target, restartBeforeTransform }) => { + + if (target.length > 10) { + restartBeforeTransform(target.slice(0, -1)) + } + + }) + .flow(camelCase, removeDuplicates) // evilcodeex +``` + +| Parameter | Type | Default | Description | +|-----------|--------|----------|--------------------------------------| +| initial | string | required | The input string to be transformed. | + +--- + +#### `pipe().flow(...pipe)` + +Applies all transformation functions in order and returns the final output string. + +```ts +pipe("quick brown fox").flow(trim, camelCase); // quickBrownFox +``` + +| Parameter | Type | Default | Description | +|-----------|----------------------------------------|----------|------------------------------------------------| +| pipe | `((data: string) => string)[]` | required | List of transformation functions to apply. | + +--- + +#### `pipe().effect(callback)` + +Registers an effect hook that runs after transformation with full context and pipeline controls. + +```ts +pipe("too long string!!") + .effect(({ target, restartBeforeTransform }) => { + if (target.length > 10) { + restartBeforeTransform(target.slice(0, 10)); + } + }) + .flow(removeSymbols); // toolongstr +``` + +| Parameter | Type | Default | Description | +|-----------|----------------|----------|----------------------------------------------| +| callback | `EffectArgs` | required | Hook with full access to pipeline control. | + +--- + +#### `pipe().raw(...pipe)` + +Same as `.flow()`, but returns a full result object including the transformation history and utilities. + +```ts +const result = pipe("hello evil evil code") + .effect(({ history }) => console.log(history)) + .raw(removeDuplicates); + +console.log(result.output); // helloEvilCode +``` + +| Parameter | Type | Default | Description | +|-----------|----------------------------------------|----------|----------------------------------------------| +| pipe | `((data: string) => string)[]` | required | List of transformation functions to apply. | + +--- + +#### `EffectArgs` (effect context) + +| Property | Type | Description | +|--------------------------|-------------------------------------------------|--------------------------------------------------------------------------| +| `history` | `Array<{ operation: string, value: string }>` | Log of each transformation step and its result. | +| `first` | `string` | Original string before any transformations. | +| `target` | `string` | Final result after transformations. | +| `pipe` | `((data: string) => string)[]` | The transformation pipeline. | +| `abort` | `() => void` | Stop pipeline execution immediately. | +| `forceStop` | `(last: string) => void` | Stop execution and return a specific value. | +| `next` | `(newString: string) => void` | Continue to the next step with a different string. | +| `restartBeforeTransform` | `(newInitial?: string) => void` | Restart pipeline from beginning before calling the effect. | +| `restartAfterTransform` | `(newInitial?: string) => void` | Restart pipeline after the effect has been called. | +| `moveToPipeIndex` | `(index: number) => void` | Jump to a specific transformation step and continue. | + + --- #### `splitChunks(text, chunkSize)` diff --git a/src/index.ts b/src/index.ts index c8295bf..37a20d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,4 @@ export default { formatting, transformations, validations, -}; +}; \ No newline at end of file diff --git a/src/tests/transformations/pipeLine.test.ts b/src/tests/transformations/pipeLine.test.ts new file mode 100644 index 0000000..670ce74 --- /dev/null +++ b/src/tests/transformations/pipeLine.test.ts @@ -0,0 +1,138 @@ +import { describe, it } from 'node:test'; +import assert, { match } from 'node:assert'; +import { pipe, pipeLine } from "../../transformations/pipe"; +import { capitalize } from '../../formatting'; +import { camelCase, removeDuplicates, truncateText } from '../../transformations'; + +describe('pipeLine', () => { + + it('can run piped function', () => { + assert.strictEqual( + pipeLine({ + initial: "hello world world", + pipe: [capitalize, removeDuplicates, camelCase] + }).output, "helloWorld" + ) + }); + + it('can abort', () => { + + assert.strictEqual( + pipeLine({ + + initial: "Phone Number: 123456", + + pipe: [ + capitalize, removeDuplicates, camelCase /*should abort here*/, (text) => truncateText(text, 5) + ], + + effect({ abort, target }) { + if (target == "phoneNumber123456") abort() + } + } + + ).output, "phoneNumber123456" + + ); + }) + + it('can restart', () => { + assert.strictEqual( + pipeLine({ + + initial: "123456", + + pipe: [ + (text) => text.slice(0, -1) + ], + + effect({ restartAfterTransform, target }) { + if (target != "") restartAfterTransform() + } + + }).output, "" + ) + }); + + it('can force stop', () => { + + assert.strictEqual( + pipeLine({ + + initial: "I ate the the apple", + + pipe: [ + removeDuplicates, (text) => text + ], + + //called after every transformation + effect({ history, forceStop, target }) { + if (target.includes('apple') && history.length > 1) { + forceStop(target.replace('apple', '*****')) + } + } + + }).output + , "I ate the *****" + ) + + }); + + it('testing restart before', () => { + + assert.strictEqual( + + pipeLine({ + initial: "Evil Code Exceeding So Much Space!", + pipe: [camelCase, removeDuplicates], + effect({ target, restartBeforeTransform }) { + + if (target.length > 10) { + //evil code + restartBeforeTransform(target.slice(0, -1)) + } + + } + }).output, "evilcodeex" + ) + + }); + + it('simple pipe test', () => { + + assert.strictEqual( + pipe("Hello World").flow(camelCase), "helloWorld" + ) + + }); + + it('testing restart before using simple pipes', () => { + + assert.strictEqual( + pipe("Evil Code Exceeding So Much Space!") + .effect(({ target, restartBeforeTransform }) => { + + if (target.length > 10) { + restartBeforeTransform(target.slice(0, -1)) + } + + }) + .flow(camelCase, removeDuplicates), + + "evilcodeex" + ) + + }); + + it('reuseable pipes', () => { + + const profanityFilter = (input: string) => pipe(input).flow(removeDuplicates, camelCase, (text) => text.replace('apple', '*****').replace("Apple", '*****')) + + assert.strictEqual( + //seems like there's an issue with question mark? + profanityFilter("Who the apple are you?"), "whoThe*****AreYou " + ) + + }) + +}) \ No newline at end of file diff --git a/src/transformations/index.ts b/src/transformations/index.ts index 187d8db..805b94d 100644 --- a/src/transformations/index.ts +++ b/src/transformations/index.ts @@ -38,6 +38,7 @@ import { numberToText } from './numberToText/main'; import { reverseWordsInString } from './reverseWordsInString '; import { stringPermutations, stringPermutationsGenerator } from './stringPermutations'; import { stringCombinations } from './stringCombinations'; +import { pipe, pipeLine } from './pipe'; export const transformations = { camelCase, @@ -60,5 +61,7 @@ export const transformations = { reverseWordsInString, stringPermutations, stringPermutationsGenerator, - stringCombinations + stringCombinations, + pipeLine, + pipe }; diff --git a/src/transformations/pipe.ts b/src/transformations/pipe.ts new file mode 100644 index 0000000..9972e7d --- /dev/null +++ b/src/transformations/pipe.ts @@ -0,0 +1,200 @@ +//TYPE DEFINITIONS ARE STORED HERE. + +export type EffectArgs = (args: { + history: { + operation: string, + value: string + }[], + first: string, + target: string, + pipe: ((data: string) => string)[], + abort: () => any, + forceStop: (last: string) => any, + next: (string: string) => any, + restartBeforeTransform: (string?: string) => any, + restartAfterTransform: (string?: string) => any, + moveToPipeIndex: (index: number) => any +}) => any + +/** + * Processes a string through a pipeline of transformation functions, optionally invoking an effect + * hook with full context and control utilities. + * + * I have made sure to make it work on any framework; i.e framework agonistic function, can be + * used in react/svelte/vue or anything for that matter. + * + * @author github.com/MayukhChakrabortyDX + * @param {Object} params - Configuration object. + * @param {string} params.initial - The initial input string to be processed. + * @param {Array<(data: string) => string>} params.pipe - An array of transformation functions to be applied sequentially. + * @param {(args: { + * history: { operation: string, value: string }[], + * first: string, + * target: string, + * pipe: Array<(data: string) => string>, + * abort: () => any, + * forceStop: (last: string) => any, + * next: (string: string) => any, + * restartBeforeTransform: (string?: string) => any, + * restartAfterTransform: (string?: string) => any, + * moveToPipeIndex: (index: number) => any + * }) => any} [params.effect] - Optional effect function that receives full control over the pipeline execution. + * + * @property {Array<{ operation: string, value: string }>} history - A log of each transformation step applied. + * @property {string} first - The original input string before any transformations. + * @property {string} target - The final string after all transformations are complete. + * @property {Array<(data: string) => string>} pipe - The array of transformation functions. + * @property {Function} abort - Stops execution immediately. + * @property {Function} forceStop - Halts execution and returns the last computed value. + * @property {Function} next - Continues processing from the next step with a given string. + * @property {Function} restartBeforeTransform - Re-runs the entire pipeline starting from the first transform. Optionally accepts a new initial string. + * @property {Function} restartAfterTransform - Re-runs the pipeline after effect is invoked. Optionally accepts a new string. + * @property {Function} moveToPipeIndex - Jumps to a specific index in the pipeline to resume processing. + */ + +export function pipeLine( + { initial, pipe, effect = () => { } }: { + initial: string, + pipe: ((data: string) => string)[], + effect?: EffectArgs + } +) { + const cacheStart = initial; + let abortSignal = false + let continueSignal = false + let restartAfterTransform = false + const history: { + operation: string, + value: string + }[] = [{ + operation: "pipe", value: cacheStart + }] + + for (let pipeIndex = 0; pipeIndex < pipe.length; pipeIndex++) { + + continueSignal = false + restartAfterTransform = false + + effect( + { + history, + first: cacheStart, + target: initial, + pipe, + abort() { + abortSignal = true + }, + forceStop(last: string) { + abortSignal = true + initial = last + }, + next(string: string) { + initial = string + continueSignal = true + }, + restartBeforeTransform(string?: string) { + if (string != undefined) initial = string + pipeIndex = 0 + }, + moveToPipeIndex(index: number) { + pipeIndex = index + }, + restartAfterTransform(string?: string) { + if (string != undefined) initial = string + restartAfterTransform = true + } + } + ) + + + if (continueSignal && !restartAfterTransform) continue; + + if (abortSignal) break; + initial = pipe[pipeIndex](initial) + history.push({ + operation: pipe[pipeIndex].name, + value: initial + }) + + if (restartAfterTransform) { pipeIndex = -1 } + + } + + return { + history, output: initial + } + +} + +export interface PipeAPI { + effect(callback: EffectArgs): PipeAPI, + flow(...pipe: ((data: string) => string)[]): string, + raw(...pipe: ((data: string) => string)[]): ReturnType +} +//lighter pipe for more readable application +export function pipe(initial: string): PipeAPI { + + const state: { + + called: { + pipe: boolean, + effect?: EffectArgs, + }, + initial: string + + } = { + called: { + pipe: false, + }, + initial + }; + + const api = new Proxy({}, { + get(_, prop) { + if (prop === "flow") { + //pipe function + return (...pipe: ((data: string) => string)[]) => { + + state.called.pipe = true; + + const output = pipeLine({ + initial: state.initial, + pipe, + effect: state.called.effect + }) + + return output.output + + + } + } else if (prop === "effect") { + + return (effect?: EffectArgs) => { + state.called.effect = effect + return api + } + + } else if (prop === "raw") { + + return (...pipe: ((data: string) => string)[]) => { + + state.called.pipe = true; + + const output = pipeLine({ + initial: state.initial, + pipe, + effect: state.called.effect + }) + + return output + + } + + } + } + }) + + //@ts-ignore + return api + +} \ No newline at end of file