From 4fb2538cbab90a4743219d9a950196dd4527ff10 Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Mon, 26 Oct 2020 02:13:03 +0100 Subject: [PATCH 1/2] chore: add test:unit:debug script - nicer reporter --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index db37033..f193823 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "posttest": "tap --coverage-report=html", "pretest:unit": "yarn standard:fix", "test:unit": "tap ./src/*.test.js ./bin/*.test.js -J", + "test:unit:debug": "tap ./bin/cli.spec.test.js -J -R spec", "test:benchmark": "tap ./benchmark/*.benchmark.test.js --no-timeout --no-coverage", "test:benchmark:glob": "yarn test:benchmark --grep=\"/input as glob pattern/\"", "test:benchmark:string": "yarn test:benchmark --grep=\"/input & replacement as strings/\"", From e7306cd5e4b5c4c05ff6364bda6ccf9a1eb253df Mon Sep 17 00:00:00 2001 From: Jakub Freisler Date: Mon, 26 Oct 2020 02:17:53 +0100 Subject: [PATCH 2/2] feat!: different output strategies support add support for `flatter` & `preserve-structure` output strategies move async & sync method to the separate files export async & sync separately & bundled into index.js extract util methods as a utils.js module tweak & create new tests BREAKING CHANGE: from this version on Node.js api ALWAYS returns an array of replace result array, where replace result array follows the pattern: [, ] BREAKING CHANGE: renamed `regex` option to `needle` BREAKING CHANGE: renamed `inputJoinString` option to `outputJoinString` --- README.md | 74 ++-- async.js | 1 + .../multiple-file-replace.benchmark.test.js | 39 +- bin/cli.js | 68 +-- bin/cli.spec.test.js | 145 ++++--- index.js | 5 +- src/async.js | 104 +++++ src/replace.js | 129 ------ src/replace.spec.test.js | 402 ++++++++++++++---- src/sync.js | 88 ++++ src/utils.js | 22 + sync.js | 1 + 12 files changed, 735 insertions(+), 343 deletions(-) create mode 100644 async.js create mode 100644 src/async.js delete mode 100644 src/replace.js create mode 100644 src/sync.js create mode 100644 src/utils.js create mode 100644 sync.js diff --git a/README.md b/README.md index ce876ac..77d1f59 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,17 @@ The fastest ([see benchmarks](#benchmarks)) CLI & Node wrapper around [javascript replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace) which allows on-the-fly replacing (with or without changing input files), [globbing](https://en.wikipedia.org/wiki/Glob_(programming)), [piping](https://en.wikipedia.org/wiki/Pipeline_(Unix)) and many more! * [:scroll: Installation](#scroll-installation) -* [:books: Node API usage](#books-node-api-usage) -* [:keyboard: CLI usage](#keyboard-cli-usage) -* [:mag_right: Examples](#mag_right-examples) +* [:books: Node API](#books-node-api) +* [:keyboard: CLI API](#keyboard-cli-api) +* [:mag_right: Example usage](#mag_right-example-usage) + * [1. Replace all `a` occurences with `b` from given `foo.js` file and return result / write result to the console](#1-replace-all-a-occurences-with-b-from-given-foojs-file-and-return-result--write-result-to-the-console) + * [2. Replace all `a` occurences with `b` from given `foo.js` and save result to the `foo_replaced.js`](#2-replace-all-a-occurences-with-b-from-given-foojs-and-save-result-to-the-foo_replacedjs) + * [3. Replace all `a` occurences with `b` from given array of files and save result to the `foo_replaced.js` using default `\n` as result-joining string](#3-replace-all-a-occurences-with-b-from-given-array-of-files-and-save-result-to-the-foo_replacedjs-using-default-n-as-result-joining-string) + * [4. Replace all `a` occurences with `b` from all `.js` files in `foo` directory and save result to the `foo_replaced.js` using `\n/////\n` as result-joining string](#4-replace-all-a-occurences-with-b-from-all-js-files-in-foo-directory-and-save-result-to-the-foo_replacedjs-using-nn-as-result-joining-string) + * [5. Replace all `a` occurences with `b` in given content string `abcd` and save result to the `foo_replaced.js`](#5-replace-all-a-occurences-with-b-in-given-content-string-abcd-and-save-result-to-the-foo_replacedjs) + * [6. Replace all `a` occurences with `b` from piped stream and save it to the output file](#6-replace-all-a-occurences-with-b-from-piped-stream-and-save-it-to-the-output-file) + * [7. Replace all `a` occurences with `b` from piped stream and pass it through `stdout` stream to the ``](#7-replace-all-a-occurences-with-b-from-piped-stream-and-pass-it-through-stdout-stream-to-the-next-command) + * [8. Both pipe & options styles can be mixed together, here - getting input from `-i` argument and passing output down the stream to the ``](#8-both-pipe--options-styles-can-be-mixed-together-here---getting-input-from--i-argument-and-passing-output-down-the-stream-to-the-next-command) * [:chart_with_upwards_trend: Benchmarks](#chart_with_upwards_trend-benchmarks) ## :scroll: Installation @@ -33,33 +41,42 @@ npm install @frsource/frs-replace or download [zipped from `@frsource/frs-replace` releases](https://github.com/FRSource/frs-replace/releases) -## :books: Node API usage +## :books: Node API -@frsource/frs-replace package provides 2 methods for synchronous / asynchronous (with promise and ES6 `async`/`await` syntax support) usage: +@frsource/frs-replace package provides 2 methods: for synchronous or for asynchronous (with promise and ES6 `async`/`await` syntax support) usage: ```javascript const FRSReplace = require('@frsource/frs-replace'); +// or +import * as FRSReplace from '@frsource/frs-replace'; FRSReplace.sync({/* options */}) FRSReplace.async({/* options */}) + +// you might also want to import methods separately (e.g. import only one of them): +const FRSReplaceSync = require('@frsource/frs-replace/sync'); +const FRSReplaceAsync = require('@frsource/frs-replace/async'); +// or +import { sync, async } from '@frsource/frs-replace'; ``` Where `/* options */` is an object containing: -> Note: remember that you need to provide some input for @frsource/frs-replace to work, so one of the parameters: input or content is **required** +> Note: remember that you need to provide some input for @frsource/frs-replace to work, so **one of parameters: input or content is required** | Option | Type | Default | Description | | --- | --- | --- | --- | - | input | string or string[] | *undefined* | Path to files or [fast-glob](https://github.com/mrmlnc/fast-glob) pattern pointing to files to be read & replaced from. If multiple files specified results will be joined using `inputJoinString` option's value) | + | input | string or string[] | *undefined* | Path to files or [fast-glob](https://github.com/mrmlnc/fast-glob) pattern pointing to files to be read & replaced from. If multiple files specified results will be joined using `outputJoinString` option's value) | | inputReadOptions | string or object | utf8 | Options which are passed directly to the [readFileSync method](https://nodejs.org/api/fs.html#fs_fs_readfilesync_path_options) when reading input file | | inputGlobOptions | object | *undefined* | Options which are passed directly to the [fast-glob package](https://github.com/mrmlnc/fast-glob#options-1) when resolving glob patterns | - | inputJoinString | string | \n | String used when joining multiple files, passed directly to [javascript join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#Syntax) | | content | string | *undefined* | Content to be replaced (takes precedence over file input) | - | regex | string or [RegExp Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax)| *-* | Used as a first argument of [javascript replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Syntax) | + | strategy | "join" or "flatten" or "preserve-structure" | "join" | Output file generation strategy. *"join"* - joins all input files and outputs them as a single file using path passed as: *"output"*. *"preserve-structure"* - saves all files to the *"output"* directory keeping relative directory structure.*"flatten"* - same as *"preserve-structure"* but flattens the directory structure | + | needle | string or [RegExp Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax)| *-* | Used as a first argument of [javascript replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Syntax) | | replacement | string | *-* | Passed as a second argument to [javascript replace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Syntax) | | output | string | *undefined* | Path of an output file | - | outputWriteOptions | string or object | utf8 | Passed as options argument of [write's .sync](https://www.npmjs.com/package/write#sync) | + | outputWriteOptions | string or object | "utf8" | Passed as options argument of [write's .sync](https://www.npmjs.com/package/write#sync) | + | outputJoinString | string | \n | String used when joining multiple files, passed directly to [javascript join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#Syntax) | -## :keyboard: CLI usage +## :keyboard: CLI API ```bash frs-replace [options] @@ -80,24 +97,25 @@ frs-replace [options] | Option | Type | Default | Description | | --- | --- | --- | --- | - |‑i, ‑‑input | string or string[] | *-* | Path to files or [fast-glob](https://github.com/mrmlnc/fast-glob) pattern pointing to files to be read & replaced from. If multiple files specified results will be joined using `inputJoinString` option's value) | + |‑i, ‑‑input | string or string[] | *-* | Path to files or [fast-glob](https://github.com/mrmlnc/fast-glob) pattern pointing to files to be read & replaced from. If multiple files specified results will be joined using `outputJoinString` option's value) | | ‑‑i-read-opts | string or object | utf8 | Options which are passed directly to the [readFileSync method](https://nodejs.org/api/fs.html#fs_fs_readfilesync_path_options) when reading input file | | ‑‑i-glob-opts | object | *undefined* | Options which are passed directly to the [fast-glob package](https://github.com/mrmlnc/fast-glob#options-1) when resolving glob patterns | - | ‑‑i-join-str | string | \n | Used when joining multiple files, passed directly to [javascript join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#Syntax) | | ‑o, ‑‑output | string | *-* | Output file name/path (replaces the file if it already exists and creates any intermediate directories if they don't already exist) | | ‑‑o-write-opts | string or object | utf8 | Passed as options argument of [write's .sync](https://www.npmjs.com/package/write#sync) | - | ‑f, ‑‑flags | combination of *gim* flags | g | [RegExp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax) flags | + | ‑‑o-join-str | string | \n | Used when joining multiple files, passed directly to [javascript join](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join#Syntax) | | ‑c, ‑‑content | string | *-* | Content to be replaced (takes precedence over stream & file input) | - | ‑‑stdout | boolean | true if piped input present, false otherwise | Force sending output on stdout | + | ‑s, ‑‑strategy | "join" or "flatten" or "preserve-structure" | "join" | Output file generation strategy. *"join"* - joins all input files and outputs them as a single file using path passed as: *"output"*. *"preserve-structure"* - saves all files to the *"output"* directory keeping relative directory structure.*"flatten"* - same as *"preserve-structure"* but flattens the directory structure | + | ‑f, ‑‑flags | combination of *gim* flags | g | [RegExp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax) flags | + | ‑‑stdout | boolean | *true* if piped input present, *false* otherwise | Force sending output on stdout | | ‑r, ‑‑replace‑fn | boolean | false | Treat replacement argument as path to file containing replacement function | | ‑h, ‑‑help | boolean | *-* | Show help | | ‑v, ‑‑version | boolean | *-* | Show version number | -## :mag_right: Examples +## :mag_right: Example usage > Note: While most of examples are using synchronous API method in all cases `.async` is applicable as well. -### 1. Replace all `a` occurences with `b` from given `foo.js` file and return result / write result to console +### 1. Replace all `a` occurences with `b` from given `foo.js` file and return result / write result to the console
Click to expand @@ -184,13 +202,13 @@ const result = require('@frsource/frs-replace').sync({ #### 3.2 CLI ```bash -frs-replace a b -i foo.js foo2.js -o foo_replaced.js --i-join-str "\n/////\n" +frs-replace a b -i foo.js foo2.js -o foo_replaced.js ``` or ```bash -frs-replace a b -i foo.js -i foo2.js -o foo_replaced.js --i-join-str "\n/////\n" +frs-replace a b -i foo.js -i foo2.js -o foo_replaced.js ``` > Note: Arrays can be passed under single flag-entry as a space-separated list *or* under same flag repeated multiple times (all values will be concatenated into single array using, details - [yargs array notation](https://github.com/yargs/yargs/blob/master/docs/tricks.md#arrays)). @@ -208,7 +226,7 @@ const result = require('@frsource/frs-replace').sync({ input : 'foo/*.js', regex : new RegExp('a', 'g'), replacement : 'b', - inputJoinString : '\n/////\n', + outputJoinString : '\n/////\n', output : 'foo_replaced.js' }) ``` @@ -216,7 +234,7 @@ const result = require('@frsource/frs-replace').sync({ #### 4.2 CLI ```bash -frs-replace a b -i foo/*.js -o foo_replaced.js --i-join-str "\n/////\n" +frs-replace a b -i foo/*.js -o foo_replaced.js --o-join-str "\n/////\n" ```
@@ -276,20 +294,20 @@ frs-replace a b -i foo.js | | Library (best bolded) | Execution time [s] | Difference percentage (comparing to best time) | | --- | --- | --- | -| frs-replace async | 0.01546155 | 84.3014% | -| **frs-replace sync** | 0.00838927 | 0.0000% | -| replace-in-file | 0.02182254 | 160.1244% | +| frs-replace async | 0.01362554 | 34.9580% | +| **frs-replace sync** | 0.01009613 | 0.0000% | +| replace-in-file | 0.02028758 | 100.9440% | | replace async | *N/A* | *N/A* | -| replace sync | 0.04994809 | 495.3804% | +| replace sync | 0.05186623 | 413.7238% | | replace-string | *N/A* | *N/A* | ### input & replacement as strings [1000 iterations x 100 repetitions] | Library (best bolded) | Execution time [s] | Difference percentage (comparing to best time) | | --- | --- | --- | -| frs-replace async | 0.00003655 | 17.4625% | -| **frs-replace sync** | 0.00003112 | 0.0000% | +| frs-replace async | 0.00023470 | 472.5367% | +| frs-replace sync | 0.00004455 | 8.6850% | | replace-in-file | *N/A* | *N/A* | | replace async | *N/A* | *N/A* | | replace sync | *N/A* | *N/A* | -| replace-string | 0.00003231 | 3.8462% | +| **replace-string** | 0.00004099 | 0.0000% | diff --git a/async.js b/async.js new file mode 100644 index 0000000..9e4157e --- /dev/null +++ b/async.js @@ -0,0 +1 @@ +module.exports = require('./src/sync') diff --git a/benchmark/multiple-file-replace.benchmark.test.js b/benchmark/multiple-file-replace.benchmark.test.js index c4b166c..3b17e65 100644 --- a/benchmark/multiple-file-replace.benchmark.test.js +++ b/benchmark/multiple-file-replace.benchmark.test.js @@ -5,7 +5,8 @@ const fs = require('fs') const perfy = require('perfy') const glob = require('fast-glob') -const FRSreplace = require('../src/replace') +const FRSreplaceSync = require('../src/sync') +const FRSreplaceAsync = require('../src/async') const replace = require('replace') const replaceInFile = require('replace-in-file') const replaceString = require('replace-string') @@ -22,7 +23,7 @@ const tmpPrefixes = { const defaults = { inputReadOptions: 'utf8', outputWriteOptions: 'utf8', - inputJoinString: '\n' + outputJoinString: '\n' } const repetitionsNo = 100000 const iterationsNo = 1000 @@ -109,14 +110,14 @@ tap.teardown(() => { fs.writeFileSync('./README.md', readmeContent.replace(/(##\s:chart_with_upwards_trend:\sBenchmarks)[\s\S]*?(?:$|(?:\s##\s))/, `$1\n\n> Tested on Node ${process.version}.\n${perfyResults}`)) }) -tap.test(`input as glob pattern [${inputFilesNo} files x ${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async ct => { - const results = await multipleTests(ct, [ +tap.test(`input as glob pattern [${inputFilesNo} files x ${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async t => { + const results = await multipleTests(t, [ { - fn: () => FRSreplace.async(testInput.FRSReplace), + fn: () => FRSreplaceAsync(testInput.FRSReplace), before: () => (testInput.FRSReplace.input = `${dir}/${tmpPrefixes.input}*`) }, { - fn: () => FRSreplace.sync(testInput.FRSReplace), + fn: () => FRSreplaceSync(testInput.FRSReplace), before: () => (testInput.FRSReplace.input = `${dir}/${tmpPrefixes.input}*`) }, { @@ -133,26 +134,26 @@ tap.test(`input as glob pattern [${inputFilesNo} files x ${iterationsNo} iterati undefined ]) - const result = outputPerfy(ct, results, results.slice().sort(sortByNumberVariable('fullNanoseconds'))[0]) + const result = outputPerfy(t, results, results.slice().sort(sortByNumberVariable('fullNanoseconds'))[0]) const sortedResults = result.results.slice().sort(sortByNumberVariable('avgTime')) - ct.is((sortedResults[0].name.indexOf('frs-replace sync') !== -1 || (sortedResults[1].name.indexOf('frs-replace sync') !== -1 && sortedResults[1].avgPercentageDifference < 5)), true, 'frs-replace sync should be the fastest or second, but at most with 5% difference to best') - ct.is(sortedResults[0].name.indexOf('frs-replace async') !== -1 || sortedResults[1].name.indexOf('frs-replace async') !== -1, true, 'frs-replace async should be the fastest or second') + t.is((sortedResults[0].name.indexOf('frs-replace sync') !== -1 || (sortedResults[1].name.indexOf('frs-replace sync') !== -1 && sortedResults[1].avgPercentageDifference < 5)), true, 'frs-replace sync should be the fastest or second, but at most with 5% difference to best') + t.is(sortedResults[0].name.indexOf('frs-replace async') !== -1 || sortedResults[1].name.indexOf('frs-replace async') !== -1, true, 'frs-replace async should be the fastest or second') - ct.end() + t.end() }) -tap.test(`input & replacement as strings [${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async ct => { - const results = await multipleTests(ct, [ +tap.test(`input & replacement as strings [${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async t => { + const results = await multipleTests(t, [ { - fn: () => FRSreplace.async(testInput.FRSReplace), + fn: () => FRSreplaceAsync(testInput.FRSReplace), before: () => { testInput.FRSReplace.regex = regex.source testInput.FRSReplace.content = content } }, { - fn: () => FRSreplace.sync(testInput.FRSReplace), + fn: () => FRSreplaceSync(testInput.FRSReplace), before: () => { testInput.FRSReplace.regex = regex.source testInput.FRSReplace.content = content @@ -164,12 +165,12 @@ tap.test(`input & replacement as strings [${iterationsNo} iterations x ${repetit { fn: () => replaceString(content, regex.source, replacement) } ]) - const result = outputPerfy(ct, results, results.slice().sort(sortByNumberVariable('fullNanoseconds'))[0]) + const result = outputPerfy(t, results, results.slice().sort(sortByNumberVariable('fullNanoseconds'))[0]) const sortedResults = result.results.slice().sort(sortByNumberVariable('avgTime')) - ct.is((sortedResults[0].name.indexOf('frs-replace') !== -1 || (sortedResults[1].name.indexOf('frs-replace') !== -1 && sortedResults[1].avgPercentageDifference < 10)), true, 'frs-replace should be the fastest or second, but at most with 10% difference to best') + t.is((sortedResults[0].name.indexOf('frs-replace') !== -1 || (sortedResults[1].name.indexOf('frs-replace') !== -1 && sortedResults[1].avgPercentageDifference < 15)), true, 'frs-replace should be the fastest or second, but at most with 15% difference to best') - ct.end() + t.end() }) function outputPerfy (t, testResults, best) { @@ -248,7 +249,7 @@ async function multipleTests (t, testCfgs, n, iterations) { const { v: testCfg, i: index } = testCfgs[k] const prevResult = results[index] const libName = testedLibraries[index] - await t.test(`${t.name} - ${libName}`, async ct => { + await t.test(`${t.name} - ${libName}`, async t => { for (let i = 0; i < n; ++i) { testCfg.before && testCfg.before() const result = await singleTest(libName, testCfg.fn, iterations) @@ -264,7 +265,7 @@ async function multipleTests (t, testCfgs, n, iterations) { } } } - ct.end() + t.end() }) } diff --git a/bin/cli.js b/bin/cli.js index 3c68178..cad7a9b 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +const replaceSync = require('../sync') require('get-stdin')().then((stdin) => { const isPiped = !!stdin @@ -16,9 +17,9 @@ require('get-stdin')().then((stdin) => { 'camel-case-expansion': false }) .scriptName('frs-replace') - .usage('$0 [options]', 'Replace matching parts of string with replacement string/function', (yargs) => { + .usage('$0 [options]', 'Replace matching parts of string with replacement string/function', (yargs) => { yargs - .positional('regex', { + .positional('needle', { describe: 'String which is passed as a first parameter to RegExp constructor', type: 'string', demand: true @@ -40,9 +41,17 @@ require('get-stdin')().then((stdin) => { 'Replace all "a" occurences with "b" in given "abcd" and save result (which is "bbcd") to foo_replaced.js') }) + .option('f') + .alias('f', 'flags') + .describe('f', 'RegExp flags used together with `needle` positional (supporting g, i & m)') + .nargs('f', 1) + .choices('f', ['', 'g', 'm', 'i', 'gm', 'gi', 'mi', 'mg', 'ig', 'im', 'gmi', 'gim', 'mig', 'mgi', 'igm', 'img']) + .default('f', 'g') + .coerce('f', arg => arg.trim()) + .option('i', { demandOption: !isContentPresent && !isHelpPresent }) .alias('i', 'input') - .describe('i', 'File to read & replace from') + .describe('i', 'Path to files or fast-glob pattern pointing to files to read & replace from') .array('i') .option('i-read-opts') @@ -54,14 +63,26 @@ require('get-stdin')().then((stdin) => { .describe('i-glob-opts', 'Passed to fast-glob.sync when resolving glob patterns') .implies('i-glob-opts', 'i') - .option('i-join-str') - .describe('i-join-str', 'Used when joining multiple files') - .default('i-join-str', undefined, 'newline (\\n)') // will use node's default value - .implies('i-join-str', 'i') + .option('c', { demandOption: isContentPresent }) + .alias('c', 'content') + .describe('c', 'Content to be replaced (takes precedence over stream & file input)') + .string('c') + .nargs('c', 1) + + .option('s') + .alias('s', 'strategy') + .describe('s', `output file generation strategy. +\`join\` - join all input files and saves single file using "output" option as it's path, +\`preserve-structure\` - when replacing files content copies them over to the directory specified by "output" option. +\`flatten\` - same as \`preserve-structure\` but flattens the directory structure`) + .nargs('s', 1) + .choices('s', ['join', 'flatten', 'preserve-structure']) + .default('s', 'join') + .coerce('s', arg => arg.trim()) .option('o') .alias('o', 'output') - .describe('o', 'Output file name/path (replaces the file if it already exists and creates any intermediate directories if they don\'t already exist)') + .describe('o', 'Output file name/path (replaces the file if it already exists and creates any intermediate directories if they don\'t already exist) or (when used together with `strategy` = `flatten` or `preserve-structure`) path to the output directory') .string('o') .nargs('o', 1) @@ -70,19 +91,9 @@ require('get-stdin')().then((stdin) => { .default('o-write-opts', undefined, 'utf8') // will use node's default value .implies('o-write-opts', 'o') - .option('f') - .alias('f', 'flags') - .describe('f', 'RegExp flags (supporting gim)') - .nargs('f', 1) - .choices('f', ['', 'g', 'm', 'i', 'gm', 'gi', 'mi', 'mg', 'ig', 'im', 'gmi', 'gim', 'mig', 'mgi', 'igm', 'img']) - .default('f', 'g') - .coerce('f', arg => arg.trim()) - - .option('c', { demandOption: isContentPresent }) - .alias('c', 'content') - .describe('c', 'Content to be replaced (takes precedence over stream & file input)') - .string('c') - .nargs('c', 1) + .option('o-join-str') + .describe('o-join-str', 'String used when joining multiple files (use it together with either `output` or `stdout` option)') + .default('o-join-str', undefined, 'newline (\\n)') // will use node's default value .option('stdout') .describe('stdout', 'Force sending output on stdout') @@ -104,20 +115,21 @@ require('get-stdin')().then((stdin) => { .argv try { - const result = require('../src/replace').sync({ - content: argv.c, + const result = replaceSync({ input: argv.i, inputReadOptions: argv['i-read-opts'], inputGlobOptions: argv['i-glob-opts'], - inputJoinString: argv['i-join-str'], - regex: new RegExp(argv.regex, argv.f), - replacement: argv.r ? require(argv.replacement) : argv.replacement, + content: argv.c, + strategy: argv.strategy, output: argv.o, - outputWriteOptions: argv['o-write-opts'] + outputWriteOptions: argv['o-write-opts'], + outputJoinString: argv['o-join-str'], + needle: new RegExp(argv.needle, argv.f), + replacement: argv.r ? require(argv.replacement) : argv.replacement }) if (argv.stdout) { - process.stdout.write(result) + process.stdout.write(result[0][1]) } return process.exit() diff --git a/bin/cli.spec.test.js b/bin/cli.spec.test.js index c269df8..ef1121e 100644 --- a/bin/cli.spec.test.js +++ b/bin/cli.spec.test.js @@ -15,15 +15,15 @@ const defaultOptions = { const content = `aąbcćdeęfg%hi jklmn oópqr,stuvwxyZ` -const regex = '^[adjox]' +const needle = '^[adjox]' const defaultFlags = 'g' const replacement = 'ą|' const replaceFn = () => replacement -const expectedOutput = content.replace(new RegExp(regex, defaultFlags), replacement) +const expectedOutput = content.replace(new RegExp(needle, defaultFlags), replacement) const defaults = { inputReadOptions: 'utf8', outputWriteOptions: 'utf8', - inputJoinString: '\n' + outputJoinString: '\n' } let output, dir @@ -41,7 +41,9 @@ let output, dir } tap.afterEach((done) => { - fs.existsSync(output) && fs.unlinkSync(output) + fs.existsSync(output) && (fs.lstatSync(output).isDirectory() + ? fs.rmdirSync(output) + : fs.unlinkSync(output)) done() }) @@ -73,9 +75,9 @@ tap.test('two arguments', (t) => { }) tap.test('content argument', async (t) => { - await checkEachArgCombinations( + await checkEachArgCombination( t, - [regex, replacement, '--stdout'], + [needle, replacement, '--stdout'], ['-c', '--content'], content, (ct, result) => { @@ -91,7 +93,7 @@ tap.test('content argument', async (t) => { }) tap.test('no stdout argument', (t) => { - const result = runCli([regex, replacement, '--content', content]) + const result = runCli([needle, replacement, '--content', content]) t.is(result.status, 0, 'process should send success status (0)') t.is(result.parsedOutput, '', 'stdout should be empty') t.is(result.parsedError, '', 'stderr should be empty') @@ -100,7 +102,7 @@ tap.test('no stdout argument', (t) => { }) tap.test('stdout argument', (t) => { - const result = runCli([regex, replacement, '--content', content, '--stdout']) + const result = runCli([needle, replacement, '--content', content, '--stdout']) t.is(result.status, 0, 'process should send success status (0)') t.is(result.parsedOutput, expectedOutput, 'stdout should contain replaced string') t.is(result.parsedError, '', 'stderr should be empty') @@ -141,9 +143,9 @@ tap.test('input argument', async (t) => { t.afterEach(cleanInputs) await t.test('as single file path', async ct => { - await checkEachArgCombinations( + await checkEachArgCombination( ct, - [regex, replacement, '--stdout'], + [needle, replacement, '--stdout'], ['-i', '--input'], () => input.path, (cct, result) => { @@ -158,17 +160,67 @@ tap.test('input argument', async (t) => { ct.end() }) - await t.test('as array of file paths', async ct => { - await checkEachArgCombinations( + await t.test('as array of file paths with strategy="flatten"', async ct => { + const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) + await checkEachArgCombination( ct, - [regex, replacement, '--stdout'], + [needle, replacement, '--stdout', '--strategy', 'flatten', '--output', outputPath], ['-i', '--input'], () => [input.path, input2.path], (cct, result) => { cct.is(result.status, 0, 'process should send success status (0)') - cct.is(result.parsedOutput, expectedOutput + defaults.inputJoinString + expectedOutput, 'stdout should contain replaced string') + cct.is(result.parsedOutput, expectedOutput, 'stdout should contain replaced string') + cct.is(result.parsedError, '', 'stderr should be empty') + + const outputFilePath = path.join(outputPath, input.path.substring(input.path.lastIndexOf(path.sep))) + const outputFileContent = fs.readFileSync(outputFilePath).toString() + ct.is(outputFileContent, expectedOutput, 'expected output saved to file') + + const outputFilePath2 = path.join(outputPath, input2.path.substring(input2.path.lastIndexOf(path.sep))) + const outputFileContent2 = fs.readFileSync(outputFilePath2).toString() + ct.is(outputFileContent2, expectedOutput, 'expected output saved to file') + + fs.unlinkSync(outputFilePath) + fs.unlinkSync(outputFilePath2) + + cct.end() + } + ) + + ct.end() + }) + + await t.test('as array of file paths with strategy="preserve-structure"', async ct => { + const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) + await checkEachArgCombination( + ct, + [needle, replacement, '--stdout', '--strategy', 'preserve-structure', '--output', outputPath], + ['-i', '--input'], + () => [input.path, input2.path], + (cct, result) => { + cct.is(result.status, 0, 'process should send success status (0)') + cct.is(result.parsedOutput, expectedOutput, 'stdout should contain replaced string') cct.is(result.parsedError, '', 'stderr should be empty') + const outputFilePath = path.join(outputPath, input.path) + const outputFileContent = fs.readFileSync(outputFilePath).toString() + ct.is(outputFileContent, expectedOutput, 'expected output saved to file') + + const outputFilePath2 = path.join(outputPath, input2.path) + const outputFileContent2 = fs.readFileSync(outputFilePath2).toString() + ct.is(outputFileContent2, expectedOutput, 'expected output saved to file') + + fs.unlinkSync(outputFilePath) + fs.unlinkSync(outputFilePath2) + + const splittedPath = input.path.split(path.sep) + splittedPath.pop() + while (splittedPath.length) { + const outputDir = splittedPath.join(path.sep) + fs.rmdirSync(path.join(outputPath, outputDir)) + splittedPath.pop() + } + cct.end() } ) @@ -177,14 +229,14 @@ tap.test('input argument', async (t) => { }) await t.test('as glob pattern', async ct => { - await checkEachArgCombinations( + await checkEachArgCombination( ct, - [regex, replacement, '--stdout'], + [needle, replacement, '--stdout'], ['-i', '--input'], `${dir}/${tmpPrefixes.input}*`, (cct, result) => { cct.is(result.status, 0, 'process should send success status (0)') - cct.is(result.parsedOutput, expectedOutput + defaults.inputJoinString + expectedOutput, 'stdout should contain replaced string') + cct.is(result.parsedOutput, expectedOutput + defaults.outputJoinString + expectedOutput, 'stdout should contain replaced string') cct.is(result.parsedError, '', 'stderr should be empty') cct.end() @@ -218,7 +270,7 @@ tap.test('i-read-opts argument', async (t) => { await t.test('without input argument', async (ct) => { const result = runCli( - [regex, replacement, '--i-read-opts.encoding', defaults.inputReadOptions, '--stdout'], + [needle, replacement, '--i-read-opts.encoding', defaults.inputReadOptions, '--stdout'], { input: content } ) @@ -231,7 +283,7 @@ tap.test('i-read-opts argument', async (t) => { await t.test('wrong with input argument', async (ct) => { const result = runCli( - [regex, replacement, '-i', input.path, '--i-read-opts.encoding', 'incorrect-encoding', '--stdout'] + [needle, replacement, '-i', input.path, '--i-read-opts.encoding', 'incorrect-encoding', '--stdout'] ) ct.is(result.status, 1, 'process should send error status (1)') @@ -243,7 +295,7 @@ tap.test('i-read-opts argument', async (t) => { await t.test('correct with input argument', async (ct) => { const result = runCli( - [regex, replacement, '-i', input.path, '--i-read-opts.encoding', defaults.inputReadOptions, '--stdout'] + [needle, replacement, '-i', input.path, '--i-read-opts.encoding', defaults.inputReadOptions, '--stdout'] ) ct.is(result.status, 0, 'process should send success status (0)') @@ -288,7 +340,7 @@ tap.test('i-glob-opts argument', async (t) => { await t.test('set without input argument', (ct) => { const result = runCli( - [regex, replacement, '--i-glob-opts.onlyDirectories', true, '--stdout'], + [needle, replacement, '--i-glob-opts.onlyDirectories', true, '--stdout'], { input: content } ) @@ -301,7 +353,7 @@ tap.test('i-glob-opts argument', async (t) => { await t.test('set with input argument', (ct) => { const result = runCli( - [regex, replacement, '-i', input.path, '--i-glob-opts.onlyDirectories', true, '--stdout'] + [needle, replacement, '-i', input.path, '--i-glob-opts.onlyDirectories', true, '--stdout'] ) ct.is(result.status, 0, 'process should send success status (0)') @@ -314,7 +366,7 @@ tap.test('i-glob-opts argument', async (t) => { t.end() }) -tap.test('i-join-str argument', async (t) => { +tap.test('o-join-str argument', async (t) => { let input, input2 t.beforeEach( @@ -344,27 +396,14 @@ tap.test('i-join-str argument', async (t) => { done() }) - await t.test('set without input argument', (ct) => { - const result = runCli( - [regex, replacement, '--i-join-str', 'sth', '--stdout'], - { input: content } - ) - - ct.is(result.status, 1, 'process should send error status (1)') - ct.is(result.parsedOutput, '', 'stdout should be empty') - ct.is(result.parsedError, 'i-join-str -> i', 'stderr contain error about missing i-join-str dependency: i argument') - - ct.end() - }) - await t.test('set with input argument', (ct) => { - const inputJoinString = 'someCustomString\n\t' + const outputJoinString = 'someCustomString\n\t' const result = runCli( - [regex, replacement, '-i', input.path, input2.path, '--i-join-str', inputJoinString, '--stdout'] + [needle, replacement, '-i', input.path, input2.path, '--o-join-str', outputJoinString, '--stdout'] ) ct.is(result.status, 0, 'process should send success status (0)') - ct.is(result.parsedOutput, expectedOutput + inputJoinString + expectedOutput, 'stdout should contain replaced string') + ct.is(result.parsedOutput, expectedOutput + outputJoinString + expectedOutput, 'stdout should contain replaced string') ct.is(result.parsedError, '', 'stderr should be empty') ct.end() @@ -375,9 +414,9 @@ tap.test('i-join-str argument', async (t) => { tap.test('output argument', async (t) => { const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) - await checkEachArgCombinations( + await checkEachArgCombination( t, - [regex, replacement, '--content', content], + [needle, replacement, '--content', content], ['-o', '--output'], outputPath, (ct, result) => { @@ -400,7 +439,7 @@ tap.test('input options argument', async (t) => { t.test('without output argument', async (ct) => { const result = runCli( - [regex, replacement, '--o-write-opts.encoding', defaults.outputWriteOptions, '--stdout'], + [needle, replacement, '--o-write-opts.encoding', defaults.outputWriteOptions, '--stdout'], { input: content } ) @@ -413,7 +452,7 @@ tap.test('input options argument', async (t) => { t.test('correct with input argument', async (ct) => { const result = runCli( - [regex, replacement, '-o', outputPath, '--o-write-opts.encoding', defaults.outputWriteOptions, '--no-stdout'], + [needle, replacement, '-o', outputPath, '--o-write-opts.encoding', defaults.outputWriteOptions, '--no-stdout'], { input: content } ) @@ -433,9 +472,9 @@ tap.test('input options argument', async (t) => { tap.test('stdin && output argument', async (t) => { const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) - await checkEachArgCombinations( + await checkEachArgCombination( t, - [regex, replacement, '--content', content, '--stdout'], + [needle, replacement, '--content', content, '--stdout'], ['-o', '--output'], outputPath, (ct, result) => { @@ -455,12 +494,12 @@ tap.test('stdin && output argument', async (t) => { tap.test('flags argument', async (t) => { const flags = 'gm' - const expectedOutput = content.replace(new RegExp(regex, flags), replacement) + const expectedOutput = content.replace(new RegExp(needle, flags), replacement) const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) - await checkEachArgCombinations( + await checkEachArgCombination( t, - [regex, replacement, '--content', content, '-o', outputPath, '--stdout'], + [needle, replacement, '--content', content, '-o', outputPath, '--stdout'], ['-f', '--flags'], flags, (ct, result) => { @@ -479,7 +518,7 @@ tap.test('flags argument', async (t) => { }) tap.test('replace-fn argument', async (t) => { - const expectedOutput = content.replace(new RegExp(regex, defaultFlags), replaceFn) + const expectedOutput = content.replace(new RegExp(needle, defaultFlags), replaceFn) const outputPath = output = tmp.tmpNameSync({ prefix: tmpPrefixes.output }) let replaceFnTmp @@ -498,9 +537,9 @@ tap.test('replace-fn argument', async (t) => { ) }) - await checkEachArgCombinations( + await checkEachArgCombination( t, - [regex, replaceFnTmp.path, '--content', content, '-o', outputPath, '--stdout'], + [needle, replaceFnTmp.path, '--content', content, '-o', outputPath, '--stdout'], ['-r', '--replace-fn'], undefined, (ct, result) => { @@ -523,7 +562,7 @@ tap.test('replace-fn argument', async (t) => { tap.test('stdin stream as input argument (like piped stream)', async (t) => { const result = runCli( - [regex, replacement, '--stdout'], + [needle, replacement, '--stdout'], { input: content } ) @@ -534,7 +573,7 @@ tap.test('stdin stream as input argument (like piped stream)', async (t) => { t.end() }) -async function checkEachArgCombinations (t, args, argCombinations, argValue, testFn) { +async function checkEachArgCombination (t, args, argCombinations, argValue, testFn) { for (const combination of argCombinations) { await t.test(combination, ct => testFn( diff --git a/index.js b/index.js index 72c974a..abca2e2 100644 --- a/index.js +++ b/index.js @@ -1 +1,4 @@ -module.exports = require('./src/replace') +module.exports = { + async: require('./src/async'), + sync: require('./src/sync') +} diff --git a/src/async.js b/src/async.js new file mode 100644 index 0000000..5d397e9 --- /dev/null +++ b/src/async.js @@ -0,0 +1,104 @@ +const write = require('write') +const path = require('path') +const fs = require('fs') +const fastGlob = require('fast-glob') +const { writeError, getReplaceFn } = require('./utils') + +const inputStrategyMap = { + join: (results, outputJoinString) => + results.then(results => [ + Promise.all(results).then(results => { + const len = results.length + let result = (results[0] && results[0][1]) || '' + for (let i = 1; i < len; ++i) { + result += outputJoinString + results[i][1] + } + return ['', result] + }) + ]), + flatten: results => + results.then(results => results.map(async result => { + result = await result + result[0] = result[0].substring(result[0].lastIndexOf(path.sep)) + return result + })), + 'preserve-structure': results => results +} + +const multipleFilesOutput = (results, output, outputWriteOptions) => { + return results.then(results => results.map( + async result => { + result = await result + result[0] = path.join(output, result[0]) + await write(result[0], result[1], outputWriteOptions) + return result + } + )) +} + +const outputStrategyMap = { + join: (results, output, outputWriteOptions) => + results.then(results => [ + results[0].then(async result => { + await write(output, result[1], outputWriteOptions) + result[0] = output + return result + }) + ]), + flatten: multipleFilesOutput, + 'preserve-structure': multipleFilesOutput +} + +module.exports = async ({ + input, + inputReadOptions = 'utf8', + inputGlobOptions, + content, + strategy = 'join', + output, + outputWriteOptions = 'utf8', + outputJoinString = '\n', + needle, + replacement +}) => { + let results + const replaceFn = getReplaceFn(needle, replacement) + + if (content !== undefined) { + results = Promise.resolve([['', replaceFn(content)]]) + } else if (input !== undefined) { + const fileStream = fastGlob.stream(input, inputGlobOptions) + const replacePromises = [] + + fileStream.on('error', writeError) + fileStream.on('data', path => replacePromises.push(new Promise((resolve, reject) => + fs.readFile(path, inputReadOptions, (error, data) => { + /* istanbul ignore next */ + if (error) return reject(error) + + resolve([path, replaceFn(data)]) + }) + ))) + results = new Promise(resolve => + fileStream.once('end', () => + resolve(replacePromises) + ) + ).catch(writeError) + } else { + writeError('at least one input source must be defined!') + } + + if (!inputStrategyMap[strategy]) writeError('unsupported strategy used! Possible values are: "join", "preserve-structure" or "flatten"') + results = inputStrategyMap[strategy](results, outputJoinString) + + if (output !== undefined) { + output = path.normalize(output) + if (typeof outputWriteOptions === 'string') { + outputWriteOptions = { encoding: outputWriteOptions } + } + + results = outputStrategyMap[strategy](results, output, outputWriteOptions) + } + + return results +} diff --git a/src/replace.js b/src/replace.js deleted file mode 100644 index d423dd4..0000000 --- a/src/replace.js +++ /dev/null @@ -1,129 +0,0 @@ -module.exports = { - sync: replaceSync, - async: replaceAsync -} - -function replaceSync ({ - input, - inputReadOptions = 'utf8', - inputGlobOptions, - inputJoinString = '\n', - content, - output, - outputWriteOptions = 'utf8', - regex, - replacement -}) { - let result = '' - const replaceFn = typeof regex === 'string' ? replaceString : replaceRegex - - if (content !== undefined) { - result = replaceFn(content, regex, replacement) - } else if (input !== undefined) { - const files = require('fast-glob').sync(input, inputGlobOptions) - - if (files.length !== 0) { - const fs = require('fs') - result = replaceFn(fs.readFileSync(files[0], inputReadOptions), regex, replacement) - - for (let i = 1, len = files.length; i < len; ++i) { - result += inputJoinString + replaceFn(fs.readFileSync(files[i], inputReadOptions), regex, replacement) - } - } - } else { - writeError('at least one input source must be defined!') - } - - if (output !== undefined) { - if (typeof outputWriteOptions === 'string') { - outputWriteOptions = { encoding: outputWriteOptions } - } - - require('write').sync(require('path').normalize(output), result, outputWriteOptions) - } - - return result -} - -async function replaceAsync ({ - input, - inputReadOptions = 'utf8', - inputGlobOptions, - inputJoinString = '\n', - content, - output, - outputWriteOptions = 'utf8', - regex, - replacement -}) { - let result - const replaceFn = typeof regex === 'string' ? replaceString : replaceRegex - - if (content !== undefined) { - result = replaceFn(content, regex, replacement) - } else if (input !== undefined) { - const fileStream = require('fast-glob').stream(input, inputGlobOptions) - const fs = require('fs') - const replacePromises = [] - const createReplacePromise = path => { - return new Promise((resolve, reject) => - fs.readFile(path, inputReadOptions, (error, data) => { - /* istanbul ignore next */ - error && reject(error) - - resolve(replaceFn(data, regex, replacement)) - }) - ) - } - - fileStream.on('error', writeError) - fileStream.on('data', path => replacePromises.push( - createReplacePromise(path) - )) - - await new Promise(resolve => - fileStream.once('end', () => - resolve(Promise.all(replacePromises)) - ) - ).then( - (strings) => (result = strings.join(inputJoinString)), - writeError - ) - } else { - writeError('at least one input source must be defined!') - } - - if (output !== undefined) { - if (typeof outputWriteOptions === 'string') { - outputWriteOptions = { encoding: outputWriteOptions } - } - - await require('write')(require('path').normalize(output), result, outputWriteOptions) - } - - return result -} - -function writeError (msg) { - throw new Error(`frs-replace :: ${msg}`) -} - -function replaceRegex (content, needle, replacement) { - return content.replace(needle, replacement) -} - -function replaceString (content, needle, replacement) { - const needleLen = needle.length - let result = '' - let i - let endIndex = 0 - - while ((i = content.indexOf(needle, endIndex)) !== -1) { - result += content.slice(endIndex, i) + replacement - endIndex = i + needleLen - } - - result += content.slice(endIndex, content.length) - - return result -} diff --git a/src/replace.spec.test.js b/src/replace.spec.test.js index 9ec6a1b..52cb697 100644 --- a/src/replace.spec.test.js +++ b/src/replace.spec.test.js @@ -4,7 +4,7 @@ const fs = require('fs') const path = require('path') const glob = require('fast-glob') -const replace = require('./replace') +const replace = require('../index.js') const tmpPrefixes = { input: 'frs-replace-replace-in', @@ -13,16 +13,45 @@ const tmpPrefixes = { const content = `aąbcćdeęfg%hi jklmn oópqr,stuvwxyZ` -const regex = new RegExp('^[adjox]', 'gm') +const needle = new RegExp('^[adjox]', 'gm') const replacement = 'ą|' const replaceFn = () => replacement const defaults = { inputReadOptions: 'utf8', outputWriteOptions: 'utf8', - inputJoinString: '\n' + outputJoinString: '\n' } let output, dir +let input, input2 + +const cleanInputs = (done) => { + input2 && input2.cleanup() + input2 = undefined + input && input.cleanup() + input = undefined + done && done() // to be runned either by node-tap or manually +} + +const createInputs = async () => { + await tmp.file({ prefix: tmpPrefixes.input, keep: true, dir }) + .then( + async f => { + input = f + return new Promise( + (resolve) => fs.appendFile(f.path, content, { encoding: defaults.inputReadOptions }, resolve) + ) + }) + await tmp.file({ prefix: tmpPrefixes.input, keep: true, dir }) + .then( + async f => { + input2 = f + return new Promise( + (resolve) => fs.appendFile(f.path, content, { encoding: defaults.inputReadOptions }, resolve) + ) + }) +} + { // removing all files similar our tmp const dirObj = tmp.dirSync() dir = dirObj.name @@ -36,16 +65,17 @@ let output, dir .forEach(fs.unlinkSync) } +tap.Test.prototype.addAssert('arrayContaining', 2, arrayContainingAssert) +tap.Test.prototype.addAssert('throwsMessageObj', 2, throwsMessageObjAssert) tap.afterEach((done) => { - fs.existsSync(output) && fs.unlinkSync(output) + fs.existsSync(output) && (fs.lstatSync(output).isDirectory() + ? fs.rmdirSync(output) + : fs.unlinkSync(output)) done() }) tap.test('check required fields', async t => { - t.throws(() => replace.sync({}), { message: 'frs-replace :: at least one input source must be defined!' }, 'sync :: should throw if both stdin & input arguments missing') - const asyncResult = replace.async({}) - asyncResult.catch(() => {}) // to silent Node "PromiseRejectionHandledWarning:" error - await t.rejects(asyncResult, { message: 'frs-replace :: at least one input source must be defined!' }, 'async :: should reject promise if both stdin & input arguments missing') + await t.throwsMessageObj({}, 'frs-replace :: at least one input source must be defined!', 'if both stdin & input arguments missing') t.end() }) @@ -56,161 +86,286 @@ tap.test('check api', async t => { t.beforeEach(async () => { testInput = { - regex, + needle, replacement } - expectedOutput = content.replace(regex, replacement) + expectedOutput = [['', content.replace(needle, replacement)]] }) - await t.test('content', async ct => { + await t.test('content', async t => { testInput.content = content - await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ct.end() - }) + await t.test('with strategy = "flatten"', async t => { + testInput.content = content + testInput.strategy = 'flatten' + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - await t.test('input', async ct => { - let input, input2 + t.end() + }) - const cleanInputs = (done) => { - input2 && input2.cleanup() - input2 = undefined - input && input.cleanup() - input = undefined - done && done() // to be runned either by node-tap or manually - } + await t.test('strategy = "preserve-structure"', async t => { + testInput.content = content + testInput.strategy = 'flatten' + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) + + t.end() + }) - ct.beforeEach(async () => { + t.end() + }) + + await t.test('input', async t => { + t.beforeEach(async () => { cleanInputs() - await tmp.file({ prefix: tmpPrefixes.input, keep: true, dir }) - .then( - async f => { - input = f - return new Promise( - (resolve) => fs.appendFile(f.path, content, { encoding: defaults.inputReadOptions }, resolve) - ) - }) - await tmp.file({ prefix: tmpPrefixes.input, keep: true, dir }) - .then( - async f => { - input2 = f - return new Promise( - (resolve) => fs.appendFile(f.path, content, { encoding: defaults.inputReadOptions }, resolve) - ) - }) + await createInputs() }) - ct.afterEach(cleanInputs) + t.afterEach(cleanInputs) - await ct.test('as single file path', async cct => { + await t.test('as single file path', async t => { testInput.input = input.path - await checkSyncAsync(cct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - await cct.test('with inputReadOptions as object', async ccct => { + await t.test('with inputReadOptions as object', async t => { testInput.input = input.path testInput.inputReadOptions = { encoding: defaults.inputReadOptions } - await checkSyncAsync(ccct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ccct.end() + t.end() }) - cct.end() + t.end() }) - await ct.test('as array of file paths', async cct => { + await t.test('as array of file paths', async t => { testInput.input = [input.path, input2.path] - await checkSyncAsync(cct, 'is', [testInput, expectedOutput + defaults.inputJoinString + expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, [[ + '', + expectedOutput[0][1] + defaults.outputJoinString + expectedOutput[0][1] + ]], 'replaced correctly']) + + await t.test('with outputJoinString changed', async t => { + testInput.input = [input.path, input2.path] + testInput.outputJoinString = 'someCustomString\n\t' - await cct.test('with inputJoinString changed', async ccct => { + await checkSyncAsync(t, 'looseEqual', [testInput, [[ + '', + expectedOutput[0][1] + testInput.outputJoinString + expectedOutput[0][1] + ]], 'replaced correctly']) + + t.end() + }) + + await t.test('with strategy = "flatten"', async t => { testInput.input = [input.path, input2.path] - testInput.inputJoinString = 'someCustomString\n\t' + testInput.strategy = 'flatten' + await checkSyncAsync(t, 'looseEqual', [testInput, [ + [input.path.substring(input.path.lastIndexOf('/')), expectedOutput[0][1]], + [input2.path.substring(input.path.lastIndexOf('/')), expectedOutput[0][1]] + ], 'replaced correctly with proper filepaths']) + + t.end() + }) - await checkSyncAsync(ccct, 'is', [testInput, expectedOutput + testInput.inputJoinString + expectedOutput, 'replaced correctly']) + await t.test('with strategy = "preserve-structure"', async t => { + testInput.input = [input.path, input2.path] + testInput.strategy = 'preserve-structure' + await checkSyncAsync(t, 'looseEqual', [testInput, [ + [input.path, expectedOutput[0][1]], + [input2.path, expectedOutput[0][1]] + ], 'replaced correctly with proper filepaths']) - ccct.end() + t.end() }) - cct.end() + t.end() }) - await ct.test('as glob pattern', async cct => { + await t.test('as glob pattern', async t => { testInput.input = `${dir}/${tmpPrefixes.input}*` - await checkSyncAsync(cct, 'is', [testInput, expectedOutput + defaults.inputJoinString + expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, [[ + '', + expectedOutput[0][1] + defaults.outputJoinString + expectedOutput[0][1] + ]], 'replaced correctly']) - await cct.test('with inputGlobOptions', async ccct => { + await t.test('with inputGlobOptions', async t => { testInput.input = `${dir}/${tmpPrefixes.input}*` testInput.inputGlobOptions = { onlyDirectories: true } - await checkSyncAsync(ccct, 'is', [testInput, '', 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, [['', '']], 'replaced correctly']) + + t.end() + }) + + await t.test('with strategy = "flatten"', async t => { + const message = 'replaced correctly with proper filepaths' + testInput.input = `${dir}/${tmpPrefixes.input}*` + testInput.strategy = 'flatten' + expectedOutput = [ + [input.path.substring(input.path.lastIndexOf(path.sep)), expectedOutput[0][1]], + [input2.path.substring(input.path.lastIndexOf(path.sep)), expectedOutput[0][1]] + ] + t.arrayContaining(await Promise.all(await replace.async(testInput)), expectedOutput, `async :: ${message}`) + t.arrayContaining(replace.sync(testInput), expectedOutput, `sync :: ${message}`) + + t.end() + }) - ccct.end() + await t.test('with strategy = "preserve-structure"', async t => { + const message = 'replaced correctly with proper filepaths' + testInput.input = `${dir}/${tmpPrefixes.input}*` + testInput.strategy = 'preserve-structure' + expectedOutput = [ + [input.path, expectedOutput[0][1]], + [input2.path, expectedOutput[0][1]] + ] + t.arrayContaining(await Promise.all(await replace.async(testInput)), expectedOutput, `async :: ${message}`) + t.arrayContaining(replace.sync(testInput), expectedOutput, `sync :: ${message}`) + + t.end() }) - cct.end() + t.end() }) - ct.end() + t.end() }) - await t.test('output', async ct => { + await t.test('strategy', async t => { + await t.test('if unsupported strategy used', async t => { + await t.throwsMessageObj({ content: 'qwe', strategy: 'whatever' }, 'frs-replace :: unsupported strategy used! Possible values are: "join", "preserve-structure" or "flatten"') + + t.end() + }) + + t.end() + }) + + await t.test('output', async t => { testInput.content = content output = testInput.output = tmp.tmpNameSync({ prefix: tmpPrefixes.output, dir }) + expectedOutput = [[ + output, + expectedOutput[0][1] + ]] - await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ct.ok(fs.existsSync(testInput.output), 'output file exists') + t.ok(fs.existsSync(testInput.output), 'output file exists') const outputFileContent = fs.readFileSync(testInput.output).toString() - ct.is(outputFileContent, expectedOutput, 'expected output saved to file') + t.is(outputFileContent, expectedOutput[0][1], 'expected output saved to file') + + t.beforeEach(async () => { + cleanInputs() + + await createInputs() + }) + + t.afterEach(cleanInputs) + + await t.test('with strategy = "flatten"', async t => { + testInput.input = [input.path, input2.path] + output = testInput.output = path.join(dir, tmpPrefixes.output, 'flatten') + testInput.strategy = 'flatten' + expectedOutput = [ + [ + path.join(dir, tmpPrefixes.output, 'flatten') + input.path.substring(input.path.lastIndexOf('/')), + expectedOutput[0][1] + ], + [ + path.join(dir, tmpPrefixes.output, 'flatten') + input2.path.substring(input.path.lastIndexOf('/')), + expectedOutput[0][1] + ] + ] + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly with proper filepaths']) + + t.ok(fs.existsSync(expectedOutput[0][0]), 'output file exists') + t.ok(fs.existsSync(expectedOutput[1][0]), 'output file 2 exists') + + fs.unlinkSync(expectedOutput[0][0]) + fs.unlinkSync(expectedOutput[1][0]) + + t.end() + }) + + await t.test('with strategy = "preserve-structure"', async t => { + testInput.input = [input.path, input2.path] + output = testInput.output = path.join(dir, tmpPrefixes.output, 'preserve-structure') + testInput.strategy = 'preserve-structure' + expectedOutput = [ + [ + path.join(dir, tmpPrefixes.output, 'preserve-structure', input.path), + expectedOutput[0][1] + ], + [ + path.join(dir, tmpPrefixes.output, 'preserve-structure', input2.path), + expectedOutput[0][1] + ] + ] + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly with proper filepaths']) + + t.ok(fs.existsSync(expectedOutput[0][0]), 'output file exists') + t.ok(fs.existsSync(expectedOutput[1][0]), 'output file 2 exists') + + fs.unlinkSync(expectedOutput[0][0]) + fs.unlinkSync(expectedOutput[1][0]) + + deleteFolderRecursive(output) + + t.end() + }) - ct.end() + t.end() }) - await t.test('outputWriteOptions as object', async ct => { + await t.test('outputWriteOptions as object', async t => { testInput.content = content output = testInput.output = tmp.tmpNameSync({ prefix: tmpPrefixes.output, dir }) testInput.outputWriteOptions = { encoding: defaults.outputWriteOptions } + expectedOutput[0][0] = output - await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ct.ok(fs.existsSync(testInput.output), 'output file exists') + t.ok(fs.existsSync(testInput.output), 'output file exists') const outputFileContent = fs.readFileSync(testInput.output).toString() - ct.is(outputFileContent, expectedOutput, 'expected output saved to file') + t.is(outputFileContent, expectedOutput[0][1], 'expected output saved to file') - ct.end() + t.end() }) - await t.test('replacement as function', async ct => { - expectedOutput = content.replace(regex, replaceFn) - + await t.test('replacement as function', async t => { testInput.content = content output = testInput.output = tmp.tmpNameSync({ prefix: tmpPrefixes.output, dir }) testInput.replacement = replaceFn - await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly']) + expectedOutput[0] = [output, content.replace(needle, replaceFn)] + + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ct.ok(fs.existsSync(testInput.output), 'output file exists') + t.ok(fs.existsSync(testInput.output), 'output file exists') const outputFileContent = fs.readFileSync(testInput.output).toString() - ct.is(outputFileContent, expectedOutput, 'expected output saved to file') + t.is(outputFileContent, expectedOutput[0][1], 'expected output saved to file') - ct.end() + t.end() }) - await t.test('regex as string', async ct => { - testInput.regex = 'a' + await t.test('needle as string', async t => { + testInput.needle = 'a' testInput.content = content - expectedOutput = content.replace(testInput.regex, replacement) + expectedOutput[0][1] = content.replace(testInput.needle, replacement) - await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly']) + await checkSyncAsync(t, 'looseEqual', [testInput, expectedOutput, 'replaced correctly']) - ct.end() + t.end() }) t.end() @@ -222,12 +377,12 @@ async function checkSyncAsync (t, method, argsSync, isFirstArgFn = false) { let messageIndex = argsSync.length - 1 while (typeof argsSync[messageIndex] !== 'string') { --messageIndex } - const argsAsync = Object.assign({}, argsSync) + const argsAsync = [...argsSync] argsAsync[0] = bindArray(replace.async, argsAsync[0]) argsSync[0] = bindArray(replace.sync, argsSync[0]) if (!isFirstArgFn) { - argsAsync[0] = await argsAsync[0]() + argsAsync[0] = await Promise.all(await argsAsync[0]()) argsSync[0] = argsSync[0]() } argsAsync[messageIndex] = `async :: ${argsAsync[messageIndex]}` @@ -240,3 +395,80 @@ async function checkSyncAsync (t, method, argsSync, isFirstArgFn = false) { function bindArray (fn, args) { return () => fn.apply(fn, args) } + +function handleRejection (promise) { + promise.catch(() => {}) // to silent Node "PromiseRejectionHandledWarning:" error + return promise +} + +function throwsMessageObjAssert (args, errorMessage, message) { + const error = { message: errorMessage } + this.throws(() => replace.sync(args), error, 'sync :: should throw ' + message) + return this.rejects(handleRejection(replace.async(args)), error, 'async :: should reject promise ' + message) +} + +function arrayContainingAssert (array, equalArray, message, extra) { + message = message || 'should contain items' + + let type; + [type, message] = arrayContaining(array, equalArray, message) + return this[type ? 'pass' : 'fail'](message, extra) +} + +function arrayContaining (array, equalArray, message) { + if (!Array.isArray(array) || !Array.isArray(equalArray) || array.length < equalArray.length) { + return [false, message] + } + + for (let i = 0; i < equalArray.length; ++i) { + const item = equalArray[i] + if (typeof item === 'string') { + if (!array.includes(item)) { + return [false, + `${message} + Expected: + ${JSON.stringify(item)} + + To be contained within: + ${JSON.stringify(array)} + ` + ] + } + } else if (Array.isArray(item)) { + for (let j = 0; j < array.length; ++j) { + const arrayItem = array[j] + const [checkResult] = arrayContaining(arrayItem, item) + if (checkResult !== false) break + else if (j === array.length - 1) { + return [false, + `${message} +Expected: +${JSON.stringify(item)} + +To be contained within: +${JSON.stringify(array)} + ` + ] + } + } + } else { + return [false, `${message} "arrayContainingAssert" unsupported type`] + } + } + + return [true, message] +} + +function deleteFolderRecursive (path) { + if (fs.existsSync(path)) { + fs.readdirSync(path).forEach(function (file) { + var curPath = path + '/' + file + if (fs.lstatSync(curPath).isDirectory()) { // recurse + deleteFolderRecursive(curPath) + } else { // delete file + fs.unlinkSync(curPath) + } + }) + fs.rmdirSync(path) + } +}; diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..b8d3f25 --- /dev/null +++ b/src/sync.js @@ -0,0 +1,88 @@ +const write = require('write') +const path = require('path') +const fs = require('fs') +const fastGlob = require('fast-glob') + +const { writeError, getReplaceFn } = require('./utils') + +const inputStrategyMap = { + join: (results, len, outputJoinString) => { + let result = (results[0] && results[0][1]) || '' + for (let i = 1; i < len; ++i) { + result += outputJoinString + results[i][1] + } + return [[['', result]], 1] + }, + flatten: (results, len) => { + for (let i = 0; i < len; ++i) { + const result = results[i] + result[0] = result[0].substring(result[0].lastIndexOf(path.sep)) + } + return [results, len] + }, + 'preserve-structure': (...args) => args +} + +const multipleFilesOutputStrategy = (results, len, output, outputWriteOptions) => { + for (let i = 0; i < len; ++i) { + const result = results[i] + result[0] = path.join(output, result[0]) + write.sync(result[0], result[1], outputWriteOptions) + } + return results +} + +const outputStrategyMap = { + join: (results, len, output, outputWriteOptions) => { + write.sync(output, results[0][1], outputWriteOptions) + results[0][0] = output + return results + }, + flatten: multipleFilesOutputStrategy, + 'preserve-structure': multipleFilesOutputStrategy +} + +module.exports = ({ + input, + inputReadOptions = 'utf8', + inputGlobOptions, + content, + strategy = 'join', + output, + outputWriteOptions = 'utf8', + outputJoinString = '\n', + needle, + replacement +}) => { + let results + const replaceFn = getReplaceFn(needle, replacement) + + if (content !== undefined) { + results = [['', replaceFn(content)]] + } else if (input !== undefined) { + results = [] + const files = fastGlob.sync(input, inputGlobOptions) + const len = files.length + for (let i = 0; i < len; ++i) { + const filePath = files[i] + results.push([filePath, replaceFn(fs.readFileSync(filePath, inputReadOptions))]) + } + } else { + writeError('at least one input source must be defined!') + } + + let len + if (!inputStrategyMap[strategy]) writeError('unsupported strategy used! Possible values are: "join", "preserve-structure" or "flatten"'); + [results, len] = inputStrategyMap[strategy](results, results.length, outputJoinString) + + if (output !== undefined) { + output = path.normalize(output) + if (typeof outputWriteOptions === 'string') { + outputWriteOptions = { encoding: outputWriteOptions } + } + + results = outputStrategyMap[strategy](results, len, output, outputWriteOptions) + } + + return results +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..cfe8e18 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,22 @@ +const writeError = msg => { throw new Error(`@frsource/frs-replace :: ${msg}`) } + +const getReplaceFn = (needle, replacement) => + typeof needle === 'string' + ? content => { + const needleLen = needle.length + let result = '' + let i + let endIndex = 0 + + while ((i = content.indexOf(needle, endIndex)) !== -1) { + result += content.slice(endIndex, i) + replacement + endIndex = i + needleLen + } + + result += content.slice(endIndex, content.length) + + return result + } + : content => content.replace(needle, replacement) + +module.exports = { writeError, getReplaceFn } diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..9e4157e --- /dev/null +++ b/sync.js @@ -0,0 +1 @@ +module.exports = require('./src/sync')