Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(877): matchall #1639

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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
Expand Down
63 changes: 63 additions & 0 deletions src/internal/ponyfills/String.matchAll.js
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions src/matchAll.js
Original file line number Diff line number Diff line change
@@ -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[][]} 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)
* 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;
230 changes: 230 additions & 0 deletions test/matchAll.js
Original file line number Diff line number Diff line change
@@ -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 = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[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 = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[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 = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[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);
});
});
});
16 changes: 16 additions & 0 deletions test/shared/matchResult.js
Original file line number Diff line number Diff line change
@@ -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;
});
6 changes: 6 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,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.
*/
Expand Down
3 changes: 3 additions & 0 deletions types/test/matchAll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as RA from 'ramda-adjunct';

RA.matchAll(/x/g, "xxx"); // $ExpectType string[][]