From 8f655419a55a1950e43be6df1da171a3a53f35e1 Mon Sep 17 00:00:00 2001 From: Loic TRUCHOT Date: Sat, 17 Oct 2020 14:06:58 +0200 Subject: [PATCH 1/2] feat(877): add matchAll + matchAll polyfill + tests --- src/index.js | 1 + src/internal/ponyfills/String.matchAll.js | 63 ++++++ src/matchAll.js | 35 ++++ test/matchAll.js | 230 ++++++++++++++++++++++ test/shared/matchResult.js | 16 ++ 5 files changed, 345 insertions(+) create mode 100644 src/internal/ponyfills/String.matchAll.js create mode 100644 src/matchAll.js create mode 100644 test/matchAll.js create mode 100644 test/shared/matchResult.js diff --git a/src/index.js b/src/index.js index cb3d694b13..7f367d7a15 100644 --- a/src/index.js +++ b/src/index.js @@ -212,6 +212,7 @@ export { default as toUinteger32 } from './toUinteger32'; export { default as toUint32 } from './toUinteger32'; // alias of to toUinteger32 // String export { default as replaceAll } from './replaceAll'; +export { default as matchAll } from './matchAll'; export { default as escapeRegExp } from './escapeRegExp'; export { default as trimStart } from './trimStart'; export { default as trimLeft } from './trimStart'; // alias of trimStart diff --git a/src/internal/ponyfills/String.matchAll.js b/src/internal/ponyfills/String.matchAll.js new file mode 100644 index 0000000000..07793fab1c --- /dev/null +++ b/src/internal/ponyfills/String.matchAll.js @@ -0,0 +1,63 @@ +const checkArguments = (searchValue, str) => { + if (str == null || searchValue == null) { + throw TypeError('Input values must not be `null` or `undefined`'); + } +}; + +const checkValue = (value, valueName) => { + if (typeof value !== 'string') { + if (!(value instanceof String)) { + throw TypeError(`\`${valueName}\` must be a string`); + } + } +}; + +const checkSearchValue = (searchValue) => { + if ( + typeof searchValue !== 'string' && + !(searchValue instanceof String) && + !(searchValue instanceof RegExp) + ) { + throw TypeError('`searchValue` must be a string or a regexp'); + } +}; + +const returnResult = (searchValue, str) => { + // make searchValue a global regexp if needed + const searchRegExp = + typeof searchValue === 'string' + ? new RegExp(searchValue, 'g') + : searchValue; + + if (searchRegExp.flags.indexOf('g') === -1) { + throw TypeError('`.matchAll` does not allow non-global regexes'); + } + + const matches = []; + let { lastIndex } = searchRegExp; + let match = searchRegExp.exec(str); + + while (match) { + matches.push(match); + + // manual lastIndex incrementation for corner cases like //g regexp + if (searchRegExp.lastIndex === lastIndex) { + lastIndex += 1; + searchRegExp.lastIndex = lastIndex; + } + + // next match + match = searchRegExp.exec(str); + } + return matches; +}; + +const matchAll = (searchValue, str) => { + checkArguments(searchValue, str); + checkValue(str, 'str'); + checkSearchValue(searchValue); + + return returnResult(searchValue, str); +}; + +export default matchAll; diff --git a/src/matchAll.js b/src/matchAll.js new file mode 100644 index 0000000000..b2f8b3e9cd --- /dev/null +++ b/src/matchAll.js @@ -0,0 +1,35 @@ +import { curryN } from 'ramda'; + +import isFunction from './isFunction'; +import ponyfill from './internal/ponyfills/String.matchAll'; + +export const matchAllPonyfill = curryN(2, ponyfill); + +export const matchAllInvoker = curryN(2, (reg, str) => [...str.matchAll(reg)]); + +/** + * Match all substring in a string. + * + * @func matchAll + * @memberOf RA + * @since {@link https://char0n.github.io/ramda-adjunct/2.29.0|v2.29.0} + * @category String + * @sig String -> String -> String + * @param {string} searchValue The substring or a global RegExp to match + * @param {string} str The String to do the search + * @return {string[]} Array with found substrings + 3 props: index, groups, input + * @throws {TypeError} When invalid arguments provided + * @see {@link https://github.com/tc39/proposal-string-matchall|TC39 proposal} + * @example + * + * RA.matchAll('ac', 'ac ab ac ab'); + * //=> [['ac'], ['ac'], ['ac']] (Arrays with "index", "input", "groups" props + * RA.matchAll(/x/g, 'xxx'); + * //=> [['x'], ['x'], ['x']] (Arrays with "index", "input", "groups" props + */ + +const matchAll = isFunction(String.prototype.matchAll) + ? matchAllInvoker + : matchAllPonyfill; + +export default matchAll; diff --git a/test/matchAll.js b/test/matchAll.js new file mode 100644 index 0000000000..aee8b49637 --- /dev/null +++ b/test/matchAll.js @@ -0,0 +1,230 @@ +import { assert } from 'chai'; +import { __ } from 'ramda'; + +import * as RA from '../src'; +import { matchAllPonyfill, matchAllInvoker } from '../src/matchAll'; +import matchResult from './shared/matchResult'; + +describe('matchAll', function () { + it('should find all matches', function () { + const input = 'ab cd ab cd ab cd'; + const actual = RA.matchAll('ab', input); + // create an array of arrays with "match" props (input, groups, index) + const expected = [0, 6, 12].map(matchResult(['ab'], input, __, undefined)); + assert.deepEqual(actual, expected); + }); + + context('given empty string', function () { + specify('should return an empty array', function () { + assert.deepEqual(RA.matchAll('ab', ''), []); + }); + }); + + context('given empty searchValue', function () { + specify('should return an occurence for each char boundary', function () { + const input = 'ab'; + const expected = [0, 1, 2].map(matchResult([''], input, __, undefined)); + assert.deepEqual(RA.matchAll('', input), expected); + }); + }); + + context('given a regexp that involves several match in a group', function () { + specify('should return an occurence of each match', function () { + const input = 'test1test2'; + const search = /t(e)(st(\d?))/g; + assert.deepEqual(RA.matchAll(search, input), [ + matchResult(['test1', 'e', 'st1', '1'], input, 0, undefined), + matchResult(['test2', 'e', 'st2', '2'], input, 5, undefined), + ]); + }); + }); + + context('given a regexp that involves named grouping', function () { + specify('should return an occurence of named group', function () { + const input = '2012-10-17'; + const search = /(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})/g; + assert.deepEqual(RA.matchAll(search, input), [ + matchResult(['2012-10-17', '2012', '10', '17'], input, 0, { + year: '2012', + month: '10', + day: '17', + }), + ]); + }); + }); + + it('should be curried', function () { + const input = 'aba'; + const expected = [ + matchResult(['a'], input, 0, undefined), + matchResult(['a'], input, 2, undefined), + ]; + assert.deepEqual(RA.matchAll('a', input), expected); + assert.deepEqual(RA.matchAll('a')(input), expected); + }); + + context('matchAllInvoker', function () { + before(function () { + if (RA.isNotFunction(String.prototype.matchAll)) { + this.skip(); + } + }); + + specify('should support global RegExp searchValue', function () { + const input = 'xxx'; + const actual = matchAllInvoker(/x/g, input); + const expected = [0, 1, 2].map(matchResult(['x'], input, __, undefined)); + + assert.deepEqual(actual, expected); + }); + + specify('should support empty searchValue', function () { + const input = 'xxx'; + const actual = matchAllInvoker('', input); + const expected = [0, 1, 2, 3].map( + matchResult([''], input, __, undefined) + ); + + assert.deepEqual(actual, expected); + }); + + specify('should find all matches', function () { + const input = 'ab cd ab cd ab cd'; + const actual = matchAllInvoker('ab', input); + const expected = [0, 6, 12].map((index) => + matchResult(['ab'], input, index, undefined) + ); + + assert.deepEqual(actual, expected); + }); + + context('given empty string', function () { + specify('should return an empty array', function () { + assert.deepEqual(matchAllInvoker('a', ''), []); + }); + }); + + context( + 'given a regexp that involves several match in a group', + function () { + specify('should return an occurence of each match', function () { + const input = 'test1test2'; + const search = /t(e)(st(\d?))/g; + assert.deepEqual(matchAllInvoker(search, input), [ + matchResult(['test1', 'e', 'st1', '1'], input, 0, undefined), + matchResult(['test2', 'e', 'st2', '2'], input, 5, undefined), + ]); + }); + } + ); + + context('given a regexp that involves named grouping', function () { + specify('should return an occurence of named group', function () { + const input = '2012-10-17'; + const search = /(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})/g; + assert.deepEqual(matchAllInvoker(search, input), [ + matchResult(['2012-10-17', '2012', '10', '17'], input, 0, { + year: '2012', + month: '10', + day: '17', + }), + ]); + }); + }); + + specify('should be curried', function () { + const input = 'aba'; + const expected = [ + matchResult(['a'], input, 0, undefined), + matchResult(['a'], input, 2, undefined), + ]; + assert.deepEqual(matchAllInvoker('a', input), expected); + assert.deepEqual(matchAllInvoker('a')(input), expected); + }); + }); + + context('matchAllPonyfill', function () { + context('given searchValue is a non-global RegExp', function () { + // warning: EcmaScript official documentation + // says that matchAll non global regex should throw an error + // (@see 21.1.3.12 in https://tc39.es/ecma262/#sec-string.prototype.matchall) + // main browsers implements that behavior + // but node.js 12+ doesn't + // that's why this test is only written for the polyfill + specify('should throw Error', function () { + assert.throws(() => matchAllPonyfill(/a/, 'abc'), TypeError); + }); + }); + + specify('should support global RegExp searchValue', function () { + const input = 'xxx'; + const actual = matchAllPonyfill(/x/g, input); + const expected = [0, 1, 2].map(matchResult(['x'], input, __, undefined)); + + assert.deepEqual(actual, expected); + }); + + specify('should support empty searchValue', function () { + const input = 'xxx'; + const actual = matchAllPonyfill('', input); + const expected = [0, 1, 2, 3].map((index) => + matchResult([''], input, index, undefined) + ); + assert.deepEqual(actual, expected); + }); + + specify('should find all matches', function () { + const input = 'ab cd ab cd ab cd'; + const actual = matchAllPonyfill('ab', input); + const expected = [0, 6, 12].map((index) => + matchResult(['ab'], input, index, undefined) + ); + + assert.deepEqual(actual, expected); + }); + + context('given empty string', function () { + specify('should return an empty array', function () { + assert.deepEqual(matchAllPonyfill('a', ''), []); + }); + }); + + context( + 'given a regexp that involves several match in a group', + function () { + specify('should return an occurence of each match', function () { + const input = 'test1test2'; + const search = /t(e)(st(\d?))/g; + assert.deepEqual(matchAllPonyfill(search, input), [ + matchResult(['test1', 'e', 'st1', '1'], input, 0, undefined), + matchResult(['test2', 'e', 'st2', '2'], input, 5, undefined), + ]); + }); + } + ); + + context('given a regexp that involves named grouping', function () { + specify('should return an occurence of named group', function () { + const input = '2012-10-17'; + const search = /(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})/g; + assert.deepEqual(matchAllPonyfill(search, input), [ + matchResult(['2012-10-17', '2012', '10', '17'], input, 0, { + year: '2012', + month: '10', + day: '17', + }), + ]); + }); + }); + + specify('should be curried', function () { + const input = 'aba'; + const expected = [ + matchResult(['a'], input, 0, undefined), + matchResult(['a'], input, 2, undefined), + ]; + assert.deepEqual(matchAllPonyfill('a', input), expected); + assert.deepEqual(matchAllPonyfill('a')(input), expected); + }); + }); +}); diff --git a/test/shared/matchResult.js b/test/shared/matchResult.js new file mode 100644 index 0000000000..99eb740acc --- /dev/null +++ b/test/shared/matchResult.js @@ -0,0 +1,16 @@ +import { curryN } from 'ramda'; +/** + * @description An helper to generate fake String.match results + * @param {string[]} val the value(s) found + * @param {number} index the place of the value in the searched string + * @param {string} input the searched string + * @param {Object|undefined} input the searched string + * @return {string[]} an array enhanced with previous props + */ +export default curryN(4, (val, input, index, groups) => { + const result = val instanceof Array ? val : [val]; + result.index = index; + result.input = input; + result.groups = groups; + return result; +}); From d2bb2ff0d4e38e8f32dc4326d46c1eb67e214cce Mon Sep 17 00:00:00 2001 From: Loic TRUCHOT Date: Sat, 17 Oct 2020 14:16:42 +0200 Subject: [PATCH 2/2] feat(877): typescript definition for matchAll --- src/matchAll.js | 6 +++--- types/index.d.ts | 6 ++++++ types/test/matchAll.ts | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 types/test/matchAll.ts diff --git a/src/matchAll.js b/src/matchAll.js index b2f8b3e9cd..1ab4effc43 100644 --- a/src/matchAll.js +++ b/src/matchAll.js @@ -17,15 +17,15 @@ export const matchAllInvoker = curryN(2, (reg, str) => [...str.matchAll(reg)]); * @sig String -> String -> String * @param {string} searchValue The substring or a global RegExp to match * @param {string} str The String to do the search - * @return {string[]} Array with found substrings + 3 props: index, groups, input + * @return {string[][]} Arrays of found substrings + index, groups, input * @throws {TypeError} When invalid arguments provided * @see {@link https://github.com/tc39/proposal-string-matchall|TC39 proposal} * @example * * RA.matchAll('ac', 'ac ab ac ab'); - * //=> [['ac'], ['ac'], ['ac']] (Arrays with "index", "input", "groups" props + * //=> [['ac'], ['ac'], ['ac']] (Arrays with "index", "input", "groups" props) * RA.matchAll(/x/g, 'xxx'); - * //=> [['x'], ['x'], ['x']] (Arrays with "index", "input", "groups" props + * //=> [['x'], ['x'], ['x']] (Arrays with "index", "input", "groups" props) */ const matchAll = isFunction(String.prototype.matchAll) diff --git a/types/index.d.ts b/types/index.d.ts index 96cb6e9b10..8cd61ee73e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1308,6 +1308,12 @@ declare namespace RamdaAdjunct { (replaceValue: string): (str: string) => string; }; + /** + * find several substring in a string, based on a pattern + */ + matchAll(searchValue: string | RegExp, str: string): string[][]; + matchAll(searchValue: string | RegExp): (str: string) => string[][]; + /** * Escapes the RegExp special characters. */ diff --git a/types/test/matchAll.ts b/types/test/matchAll.ts new file mode 100644 index 0000000000..35965daeff --- /dev/null +++ b/types/test/matchAll.ts @@ -0,0 +1,3 @@ +import * as RA from 'ramda-adjunct'; + +RA.matchAll(/x/g, "xxx"); // $ExpectType string[][]