diff --git a/.travis.yml b/.travis.yml index 4460917..7291d3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,24 @@ +dist: trusty +sudo: required language: node_js -node_js: - - "node" - - "lts/*" \ No newline at end of file +cache: yarn +matrix: + include: + - name: "Standard linting" + script: yarn standard + node_js: "lts/*" + - name: "Unit tests & coverage" + script: yarn test + node_js: + - "node" + - "lts/*" + - name: "Benchmark test - glob" + script: travis_wait yarn test:benchmark:glob + node_js: + - "node" + - "lts/*" + - name: "Benchmark test - string" + script: yarn test:benchmark:string + node_js: + - "node" + - "lts/*" \ No newline at end of file diff --git a/README.md b/README.md index 8dd78af..2b50ea0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# FRS-replace + [![NPM version](https://img.shields.io/npm/v/frs-replace.svg?style=flat)](https://www.npmjs.com/package/frs-replace) [![Build Status](https://travis-ci.org/FRSource/FRS-replace.svg?branch=master)](https://travis-ci.org/FRSource/FRS-replace) [![Coverage Status](https://coveralls.io/repos/github/FRSource/FRS-replace/badge.svg?branch=master)](https://coveralls.io/github/FRSource/FRS-replace?branch=master) @@ -5,9 +7,7 @@ [![codebeat badge](https://codebeat.co/badges/5496a006-a13d-48cc-baeb-37c79a1f6444)](https://codebeat.co/projects/github-com-frsource-frs-replace-master) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) -# FRS-replace - -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! +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! * [Installation](#installation) * [Node API usage](#node-api-usage) @@ -16,17 +16,20 @@ CLI & Node wrapper around [javascript replace](https://developer.mozilla.org/en- * [Benchmarks](#benchmarks) ## Installation -##### yarn -``` -$ yarn add frs-replace -``` -##### npm +yarn + +```bash +yarn add frs-replace ``` -$ npm install frs-replace + +npm + +```bash +npm install frs-replace ``` -##### download +download [zipped from FRS-replace Releases](https://github.com/FRSource/FRS-replace/releases) ## Node API usage @@ -61,14 +64,16 @@ Where `/* options */` is an object containing: FRS-replace [options] ``` -#### Positionals: +### Positionals + | Option | Type | Description | | --- | --- | --- | | \ | string | First parameter to [RegExp constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#Syntax) | | \ | string | String or path to replacement function file (see ‑‑replace‑fn switch for details) | -#### Options: -> Note: Every boolean option can be negated with use of `--no-` prefix, e.g. `--stdout` or `--no-stdout` turn stdout output on or off, respectively. +### Options + +> Note: Every boolean option can be negated with use of `--no-` prefix, e.g. `--stdout` or `--no-stdout` turn stdout output on or off, respectively. > Note: Object types can be set using [dot notation](https://github.com/yargs/yargs-parser#dot-notation). So, e.g. if you want to pass `utf8` value under i-read-opts encoding field you should write `--i-read-opts.encoding utf8`. @@ -91,9 +96,9 @@ FRS-replace [options] > Note: while most of examples is using synchronous API method, in all cases `.async` is applicable as well. -#### 1. Replace all `a` occurences with `b` from given `foo.js` and returns result / writes result to console : +### 1. Replace all `a` occurences with `b` from given `foo.js` and returns result / writes result to console -###### API +#### 1.1 API ```javascript const FRSReplace = require('FRS-replace') @@ -127,14 +132,16 @@ const resultAsync = await FRSReplace.async({ ``` -###### CLI +#### 1.2 CLI + ```bash FRS-replace a b -i foo.js --stdout ``` -#### 2. Replace all `a` occurences with `b` from given `foo.js` and save result to `foo_replaced.js` : +### 2. Replace all `a` occurences with `b` from given `foo.js` and save result to `foo_replaced.js` + +#### 2.1 API -###### API ```javascript const result = require('FRS-replace').sync({ input : 'foo.js', @@ -144,14 +151,16 @@ const result = require('FRS-replace').sync({ }) ``` -###### CLI +#### 2.2 CLI + ```bash FRS-replace a b -i foo.js -o foo_replaced.js ``` -#### 3. Replace all `a` occurences with `b` from given array of files and save result to `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 `foo_replaced.js` using default `\n` as result-joining string + +#### 3.1 API -###### API ```javascript const result = require('FRS-replace').sync({ input : ['foo.js', 'foo2.js'], @@ -161,12 +170,13 @@ const result = require('FRS-replace').sync({ }) ``` -###### CLI +#### 3.2 CLI + ```bash FRS-replace a b -i foo.js foo2.js -o foo_replaced.js --i-join-str "\n/////\n" ``` -*or* +or ```bash FRS-replace a b -i foo.js -i foo2.js -o foo_replaced.js --i-join-str "\n/////\n" @@ -174,9 +184,10 @@ FRS-replace a b -i foo.js -i foo2.js -o foo_replaced.js --i-join-str "\n/////\n" > 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-parser#dot-notation)). -#### 4. Replace all `a` occurences with `b` from all `.js` files in `foo` directory and save result to `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 `foo_replaced.js` using `\n/////\n` as result-joining string + +#### 4.1 API -###### API ```javascript const result = require('FRS-replace').sync({ input : 'foo/*.js', @@ -187,14 +198,16 @@ const result = require('FRS-replace').sync({ }) ``` -###### CLI +#### 4.2 CLI + ```bash FRS-replace a b -i foo/*.js -o foo_replaced.js --i-join-str "\n/////\n" ``` -#### 5. Replace all `a` occurences with `b` in given content string `abcd` and save result to `foo_replaced.js` +### 5. Replace all `a` occurences with `b` in given content string `abcd` and save result to `foo_replaced.js` + +#### 5.1 API -###### API ```javascript const result = require('FRS-replace').sync({ content : 'abcd', @@ -204,48 +217,56 @@ const result = require('FRS-replace').sync({ }) ``` -###### CLI +#### 5.2 CLI + ```bash FRS-replace a b --content abcd -o foo_replaced.js ``` -#### 6. Replace all `a` occurences with `b` from piped stream and save it to output file: +### 6. Replace all `a` occurences with `b` from piped stream and save it to output file + +#### 6.1 CLI -###### CLI ```bash | FRS-replace a b > ``` - -#### 7. Replaces all `a` occurences with `b` from piped stream and pass it through `stdout` stream to next command -###### CLI +### 7. Replaces all `a` occurences with `b` from piped stream and pass it through `stdout` stream to next command + +#### 7.1 CLI + ```bash | FRS-replace a b | ``` -#### 8. Both pipe & options styles can be mixed together, here - getting input from `i` argument and passing output down the stream to next command +### 8. Both pipe & options styles can be mixed together, here - getting input from `i` argument and passing output down the stream to next command + +#### 8.1 CLI -###### CLI ```bash FRS-replace a b -i foo.js | ``` -## Benchmarks -#### input as glob pattern [1000 iterations x 100 repetitions] +## Benchmarks (Node v10.16.0) + +### input as glob pattern [40 files x 1000 iterations x 100 repetitions] + | Library (best bolded) | Execution time [s] | Difference percentage (comparing to best time) | | --- | --- | --- | -| **FRS-replace async** | 0.36640944 | 0.0000% | -| FRS-replace sync | 0.39553770 | 7.9496% | -| replace-in-file | 1.78587186 | 387.3979% | +| FRS-replace async | 0.01761663 | 103.7503% | +| **FRS-replace sync** | 0.00864619 | 0.0000% | +| replace-in-file | 0.02154322 | 149.1644% | | replace async | *N/A* | *N/A* | -| replace sync | 0.44655926 | 21.8744% | +| replace sync | 0.05026399 | 481.3428% | | replace-string | *N/A* | *N/A* | -#### input & replacement as strings [1000 iterations x 100 repetitions] + +### 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.01015828 | 59.0095% | -| FRS-replace sync | 0.00657347 | 2.8957% | +| FRS-replace async | 0.00011756 | 215.1822% | +| **FRS-replace sync** | 0.00003730 | 0.0000% | | replace-in-file | *N/A* | *N/A* | | replace async | *N/A* | *N/A* | | replace sync | *N/A* | *N/A* | -| **replace-string** | 0.00638847 | 0.0000% | +| replace-string | 0.00004905 | 31.5049% | diff --git a/benchmark/multiple-file-replace.benchmark.test.js b/benchmark/multiple-file-replace.benchmark.test.js index fe53771..7626645 100644 --- a/benchmark/multiple-file-replace.benchmark.test.js +++ b/benchmark/multiple-file-replace.benchmark.test.js @@ -16,8 +16,8 @@ const content = `aąbcćdeęfg%hi jklmn oópqr,stuvwxyZ` const tmpPrefixes = { - input: 'FRS-replace-replace-in', - output: 'FRS-replace-replace-out' + input: 'FRS-replace-replace-in-', + output: 'FRS-replace-replace-out-' } const defaults = { inputReadOptions: 'utf8', @@ -26,7 +26,33 @@ const defaults = { } const repetitionsNo = 100000 const iterationsNo = 1000 -const testInput = {} +const inputFilesNo = 40 +const testInput = { + FRSReplace: { + regex, + replacement + }, + + replace: { + regex, + replacement, + recursive: true, + silent: true + }, + + replaceAsync: { + regex, + replacement, + async: true, + recursive: true, + silent: true + }, + + replaceInFile: { + from: regex, + to: replacement + } +} const testedLibraries = [ 'FRS-replace async', 'FRS-replace sync', @@ -36,11 +62,9 @@ const testedLibraries = [ 'replace-string' ] -let dir, output, input - -const readmeContent = fs.readFileSync('./README.md').toString() - +let dir; let output; let inputs = [] let perfyResults = '' +let tmpFilesPromise { const dirObj = tmp.dirSync() // removing all files similar our tmp @@ -53,90 +77,67 @@ let perfyResults = '' ].map(v => v + '*') ) .forEach(fs.unlinkSync) -} - -tap.beforeEach(async () => { - testInput.FRSReplace = { - regex, - replacement - } - - testInput.replace = { - regex, - replacement - } - - testInput.replaceAsync = { - regex, - replacement, - async: true - } - testInput.replaceInFile = { - from: regex, - to: replacement + const promises = [] + + for (let i = 0; i < inputFilesNo; ++i) { + promises.push( + tmp.file({ prefix: tmpPrefixes.input + i + '-', keep: true, dir }) + .then(input => { + inputs.push(input) + return new Promise(resolve => { + fs.appendFile(input.path, content, { encoding: defaults.inputReadOptions }, resolve) + }) + }) + ) } - 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) - ) - }) -}) - -const cleanInputs = (done) => { - input && input.cleanup() - input = undefined - done && done() // to be runned either by node-tap or manually + tmpFilesPromise = Promise.all(promises) } - -tap.afterEach((done) => { +tap.autoend(false) +tap.beforeEach(() => tmpFilesPromise) +tap.afterEach(done => { fs.existsSync(output) && fs.unlinkSync(output) - cleanInputs() done() }) -tap.test(`input as glob pattern [${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async ct => { +tap.teardown(() => { + inputs.forEach(input => input.cleanup) + inputs = [] + const readmeContent = fs.readFileSync('./README.md').toString() + + fs.writeFileSync('./README.md', readmeContent.replace(/(##\sBenchmarks \(Node )(?:.*?)(\)\s)[\s\S]*?(?:$|(?:\s##\s))/, `$1${process.version}$2${perfyResults}`)) +}) + +tap.test(`input as glob pattern [${inputFilesNo} files x ${iterationsNo} iterations x ${repetitionsNo / iterationsNo} repetitions]`, async ct => { const results = await multipleTests(ct, [ { fn: () => FRSreplace.async(testInput.FRSReplace), - before: () => (testInput.FRSReplace.input = `${dir}\\${tmpPrefixes.input}*`) + before: () => (testInput.FRSReplace.input = `${dir}/${tmpPrefixes.input}*`) }, { fn: () => FRSreplace.sync(testInput.FRSReplace), - before: () => (testInput.FRSReplace.input = `${dir}\\${tmpPrefixes.input}*`) + before: () => (testInput.FRSReplace.input = `${dir}/${tmpPrefixes.input}*`) }, { fn: () => replaceInFile(testInput.replaceInFile), - before: () => (testInput.replaceInFile.files = dir + require('path').sep + tmpPrefixes.input + '*') + before: () => (testInput.replaceInFile.files = `${dir}/${tmpPrefixes.input}*`) }, - // { - // fn: () => replace(testInput.replaceAsync), before: () => { - // testInput.replaceAsync.paths = [dir.replace(/\\/g, '/')] - // testInput.replaceAsync.include = `${tmpPrefixes.input}*` - // } - // }, // COMMENTED OUT - waits for better FRS-replace async methods - undefined, + undefined, // IMPORTANT: test doesn't checks replace async, because it doesn't returns when (and if) file got replaced(https://github.com/harthur/replace/issues/25) { fn: () => replace(testInput.replace), before: () => { testInput.replace.paths = [dir.replace(/\\/g, '/')] - testInput.replace.include = `${tmpPrefixes.input}*` } }, undefined ]) - const sortedResults = results.slice().sort(sortByNanoseconds) - ct.not(sortedResults[0].name.indexOf('FRS-replace'), -1, 'FRS-replace should be the fastest') - // results.map((result) => result.testCfg && ct.is(result.result, results[0].result, `${result.name} are results the same`)) + const result = outputPerfy(ct, results, results.slice().sort(sortByNumberVariable('fullNanoseconds'))[0]) + const sortedResults = result.results.slice().sort(sortByNumberVariable('avgTime')) - outputPerfy(ct, results, sortedResults[0]) + 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') ct.end() }) @@ -163,19 +164,14 @@ tap.test(`input & replacement as strings [${iterationsNo} iterations x ${repetit { fn: () => replaceString(content, regex.source, replacement) } ]) - const result = outputPerfy(ct, results, results.slice().sort(sortByNanoseconds)[0]) - - const sortedResults = result.results.slice().sort(sortByNanoseconds) + const result = outputPerfy(ct, 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') ct.end() }) -tap.teardown(() => { - fs.writeFileSync('./README.md', readmeContent.replace(/(##\sBenchmarks\s\s)[\s\S]*?(?:$|(?:\s##\s))/, '$1' + perfyResults)) -}) - function outputPerfy (t, testResults, best) { best = best.fullNanoseconds @@ -216,7 +212,7 @@ function outputPerfy (t, testResults, best) { ) perfyResults += - '#### ' + result.name + '\n' + + '\n### ' + result.name + '\n\n' + '| Library (best bolded) | Execution time [s] | Difference percentage (comparing to best time) |\n' + '| --- | --- | --- |\n' + result.results.reduce( @@ -248,13 +244,12 @@ async function multipleTests (t, testCfgs, n, iterations) { const testCfgLen = testCfgs.length - for (let i = 0; i < n; ++i) { - for (let k = testCfgLen - 1; k >= 0; --k) { - const { v: testCfg, i: index } = testCfgs[k] - const prevResult = results[index] - const libName = testedLibraries[index] - - await t.test(`${t.name} - ${libName} #${i}`, async ct => { + for (let k = testCfgLen - 1; k >= 0; --k) { + const { v: testCfg, i: index } = testCfgs[k] + const prevResult = results[index] + const libName = testedLibraries[index] + await t.test(`${t.name} - ${libName}`, async ct => { + for (let i = 0; i < n; ++i) { testCfg.before && testCfg.before() const result = await singleTest(libName, testCfg.fn, iterations) @@ -268,10 +263,9 @@ async function multipleTests (t, testCfgs, n, iterations) { } } } - - ct.end() - }) - } + } + ct.end() + }) } testCfgs.forEach(({ i: index }) => { @@ -303,14 +297,19 @@ async function singleTest (name, test, n) { return result } -function sortByNanoseconds (a, b) { - if (a.fullNanoseconds === undefined) { - return b.fullNanoseconds === undefined ? 0 : 1 - } +function sortByNumberVariable (varName) { + return (a, b) => { + a = a[varName] + b = b[varName] - if (b.fullNanoseconds === undefined) { - return -1 - } + if (a === undefined || a === null) { + return b === undefined || b === null ? 0 : 1 + } - return a.fullNanoseconds - b.fullNanoseconds + if (b === undefined || b === null) { + return -1 + } + + return a - b + } } diff --git a/package.json b/package.json index e67ba6b..9f1d482 100644 --- a/package.json +++ b/package.json @@ -35,15 +35,18 @@ "javascript" ], "scripts": { - "prerelease": "standard --fix && yarn test", + "prerelease": "yarn standard:fix && yarn test && yarn test:benchmark", "release": "standard-version", "postrelease": "git push --follow-tags origin master && yarn publish", "pretest": "standard", - "test": "yarn test:benchmark && yarn test:unit --100", + "test": "yarn test:unit --100", "posttest": "tap --coverage-report=html", - "pretest:unit": "standard --fix", + "pretest:unit": "yarn standard:fix", "test:unit": "tap ./src/*.test.js ./bin/*.test.js -J", - "test:benchmark": "tap ./benchmark/*.benchmark.test.js --no-timeout" + "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/\"", + "standard:fix": "standard --fix" }, "devDependencies": { "perfy": "^1.1.5", @@ -58,7 +61,7 @@ "dependencies": { "fast-glob": "^3.1.0", "get-stdin": "^7.0.0", - "yargs": "^14.2.0", - "write": "^2.0.0" + "write": "^2.0.0", + "yargs": "^14.2.0" } } diff --git a/src/replace.js b/src/replace.js index 87242dc..c27c8b2 100644 --- a/src/replace.js +++ b/src/replace.js @@ -1,19 +1,9 @@ module.exports = { - sync: replace, - async: (...args) => new Promise((resolve, reject) => { - let result - - try { - result = replace.apply(this, args) - } catch (e) { - return reject(e) - } - - resolve(result) - }) + sync: replaceSync, + async: replaceAsync } -function replace ({ +function replaceSync ({ input, inputReadOptions = 'utf8', inputGlobOptions, @@ -24,14 +14,13 @@ function replace ({ regex, replacement }) { - let result + 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) + const files = require('fast-glob').sync(input, inputGlobOptions) if (files.length !== 0) { const fs = require('fs') @@ -40,8 +29,6 @@ function replace ({ for (let i = 1, len = files.length; i < len; ++i) { result += inputJoinString + replaceFn(fs.readFileSync(files[i], inputReadOptions), regex, replacement) } - } else { - result = '' } } else { writeError('at least one input source must be defined!') @@ -58,6 +45,65 @@ function replace ({ 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}`) } diff --git a/src/replace.spec.test.js b/src/replace.spec.test.js index f815fb1..2c11d87 100644 --- a/src/replace.spec.test.js +++ b/src/replace.spec.test.js @@ -23,8 +23,8 @@ const defaults = { } let output, dir -{ - const dirObj = tmp.dirSync() // removing all files similar our tmp +{ // removing all files similar our tmp + const dirObj = tmp.dirSync() dir = dirObj.name glob.sync( @@ -170,10 +170,10 @@ tap.test('check api', async t => { ct.end() }) - await t.test('outputOptions as object', async ct => { + await t.test('outputWriteOptions as object', async ct => { testInput.content = content output = testInput.output = tmp.tmpNameSync({ prefix: tmpPrefixes.output, dir }) - testInput.outputOptions = { encoding: defaults.outputWriteOptions } + testInput.outputWriteOptions = { encoding: defaults.outputWriteOptions } await checkSyncAsync(ct, 'is', [testInput, expectedOutput, 'replaced correctly'])