From d988eeac341ec2744c078d48277d98ad81349390 Mon Sep 17 00:00:00 2001 From: FG Ribreau Date: Mon, 28 Dec 2015 23:38:37 +0100 Subject: [PATCH] feat(match-when): this is so much fun! --- README.md | 91 +++++++++++++++++++++++++++++++ match.js | 111 +++++++++++++++++++++++++++++++++++++ match.test.js | 112 ++++++++++++++++++++++++++++++++++++++ package.json | 16 ++++++ register/index.js | 3 + register/register.test.js | 11 ++++ 6 files changed, 344 insertions(+) create mode 100644 README.md create mode 100644 match.js create mode 100644 match.test.js create mode 100644 package.json create mode 100644 register/index.js create mode 100644 register/register.test.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d3ffde --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +match-when - Pattern matching for modern JavaScript +=================================================== + + + +Adding pattern matching to JavaScript (ES6+) through two new special *keywords*\* `match` and `when`. The main goals are **safety** and **shortness**. +There is a lot more to do but after some late work, that's all for tonight! + +*\* well, of course, they are not keywords but simple functions* + +### Usage + +The setup is pretty simple, simply require the library with `match` and `when` and you are ready to go! + +```js +const {match, when} = require('match-when'); +``` + +or globally + +```js +require('match-when/register'); // `match` and `when` are now globally available +``` + +Now let's see how we would write a factorial function: + +```js +function fact(n){ + return match({ + [when(0)]: 1, + [when()]: (n) => n * fact(n-1) + })(n); +} + +fact(10); // 3628800 +``` + +Clear and simple, note that `when()` is a catch-all pattern and should always be the last condition. If you forget, `match()` will throw a `MissingCatchAllPattern` exception. + +`match` works well with .map (and others) too: + +```js +[2, 4, 1, 2].map(match({ + [when(1)]: "one", + [when(2)]: "two", + [when()]: "many" +})); + +// [ 'two', 'many', 'one', 'two' ] +``` + +#### arrays + + +It also works with **arrays**: + +```js +function length(list){ + return match({ + [when([])]: 0, + [when()]: ([head, ...tail]) => 1 + length(tail) + })(list); +} + +length([1, 1, 1]); // 3 +``` + +#### OR + +Sadly JavaScript does not offer us a way to overload operators so we're stuck with `when.or`: + +```js +function parseArgument(arg){ + return match({ + [when.or("-h", "--help")]: () => displayHelp, + [when.or("-v", "--version")]: () => displayVersion, + [when()]: (whatever) => unknownArgument.bind(null, whatever) + })(arg); +} + +parseArgument(process.argv.slice(1)); // displayHelp || displayVersion || (binded)unknownArgument +``` + +#### AND + + + +### Supported patterns: + +- `{ x1: pattern1, ..., xn: pattern }` - matches any object with property names `x1` to `xn` matching patterns `pattern1` to `pattern`, respectively. Only the own properties of the pattern are used. +- `when.or()` diff --git a/match.js b/match.js new file mode 100644 index 0000000..c15a212 --- /dev/null +++ b/match.js @@ -0,0 +1,111 @@ +'use strict'; + +const _catchAllSymbol = Symbol('match.pattern.catchAll'); +const _patternOR = Symbol('match.pattern.OR'); +const _patternORStr = _patternOR.toString(); // dirty hack +const _patternAND = Symbol('match.pattern.AND'); +const _patternANDStr = _patternAND.toString(); // dirty hack + +function MissingCatchAllPattern() { + Error.call(this, 'Missing when() catch-all pattern as last match argument, add [when()]: void 0'); + if (!('stack' in this)){ + this.stack = (new Error).stack; + } +} + +MissingCatchAllPattern.prototype = Object.create(Error.prototype); + +function match(obj){ + var matchers = []; // pre-compute matchers + + for(let key in obj){ + matchers.push(when.unserialize(key, obj[key])); + } + + if(Object.getOwnPropertySymbols(obj).indexOf(_catchAllSymbol) === -1){ + throw new MissingCatchAllPattern(); + } + + // add catch all pattern at the end + matchers.push(when.unserialize(_catchAllSymbol, obj[_catchAllSymbol])); + + return function(input){ + for (var i = 0, iM = matchers.length; i < iM; i++) { // old school #perf + let matcher = matchers[i]; + if(matcher.match(input)){ + return typeof matcher.call === 'function' ? matcher.call(input): matcher.call; + } + } + }; + +} + +function when(props){ + if(props === undefined){ + return _catchAllSymbol; + } + + if(props === Number){ + return _numberPattern; + } + + return JSON.stringify(props); +} + +function _true(){return true;} + +// mixed -> String +function _match(props){ + + if(Array.isArray(props)){ + if(props[0] === _patternORStr){ + props.shift(); + return function(input){ + return props[0].some((prop) => _matching(prop, input)); + }; + } + } + + function _matching(props, input){ + // implement array matching + if(Array.isArray(input)){ + // @todo optimize this + return JSON.stringify(props) === JSON.stringify(input); + } + + if(typeof input === 'object'){ + for(let prop in props){ + if(input[prop] !== props[prop]){ + return false; + } + } + return true; + } + + return props === input; + } + + return (input) => _matching(props, input); +} + +// mixed -> String +when.or = function(/* args... */){ + return JSON.stringify([_patternOR.toString(), Array.prototype.slice.call(arguments)]); +}; + +// mixed -> String +when.and = function(/* args... */){ + return JSON.stringify([_patternAND.toString(), Array.prototype.slice.call(arguments)]); +}; + +when.unserialize = function(props, value){ + return { + match: props === _catchAllSymbol ? _true : _match(JSON.parse(props)), + call: value + }; +} + +module.exports = { + match, + when +}; diff --git a/match.test.js b/match.test.js new file mode 100644 index 0000000..e25494a --- /dev/null +++ b/match.test.js @@ -0,0 +1,112 @@ +'use strict'; +const when = require('./match').when; +const match = require('./match').match; + +// var match = require('./'); +const t = require('chai').assert; + +describe('match', () => { + const input = [{protocol: 'HTTP', i:10}, {protocol: 'AMQP', i:11}, {protocol: 'AMQP', i:5}, {protocol: 'WAT', i:3}]; + + it('should throw if a catch-all pattern was not specified', () => { + t.throws(() => input.map(match({ + [when({protocol:'HTTP'})]: (o) => o.i+1, + [when({protocol:'AMQP'})]: (o) => o.i+2, + }))); + }); + + describe('matching', () => { + it('should match objects based on properties', () => { + const output = input.map(match({ + [when({protocol:'HTTP', i:12})]: (o) => 1000, + [when({protocol:'HTTP'})]: (o) => o.i+1, + [when({protocol:'AMQP', i:12})]: (o) => 1001, + [when({protocol:'AMQP'})]: (o) => o.i+2, + [when()]: (o) => 0, + })); + + t.deepEqual(output, [11, 13, 7, 0]); + }); + + + it('should match arrays based on indexes and content', () => { + const output = [['a', 'b'], ['c'], ['d', 'e', 1]].map(match({ + [when(['c'])]: 1000, + [when(['a', 'b'])]: 1001, + [when([])]: 1002, + [when(['d', 'e', 1])]: 1003, + [when()]: (o) => 0 + })); + + t.deepEqual(output, [1001, 1000, 1003]); + }); + + it('should match number as well', () => { + function fact(n){ + return match({ + [when(0)]: 1, + [when()]: (n) => n * fact(n-1) // when() === catch-all + })(n); + } + + t.strictEqual(fact(10),3628800); + }); + + it('should match empty array', () => { + function length(list){ + return match({ + [when([])]: 0, + // [when()]: ([head, ...tail]) => 1 + length(tail) // still does not work in v5.3.0 + [when()]: (arr) => 1 + length(arr.slice(1)) + })(list); + } + + t.strictEqual(length([1, 2, 3]), 3); + t.strictEqual(length([{}, {}, {}, {}]), 4); + }) + + it('should support conditional matching', () => { + // example from https://kerflyn.wordpress.com/2011/02/14/playing-with-scalas-pattern-matching/ + + function parseArgument(arg){ + return match({ + [when.or("-h", "--help")]: () => displayHelp, + [when.or("-v", "--version")]: () => displayVersion, + [when()]: (whatever) => unknownArgument.bind(null, whatever) + })(arg); + } + + function displayHelp(){ + console.log('help.'); + } + + function displayVersion(){ + console.log('v0.0.0'); + } + + function unknownArgument(whatever){ + throw new Error(`command ${whatever} not found`); + } + + t.strictEqual(parseArgument('-h'), displayHelp); + t.strictEqual(parseArgument('--help'), displayHelp); + t.strictEqual(parseArgument('-v'), displayVersion); + t.strictEqual(parseArgument('--version'), displayVersion); + t.throws(() => { + parseArgument('hey')(); + }); + }); + }); + + describe('yielding', () => { + it('should also be able to yield primitive values', () => { + const output = input.map(match({ + [when({protocol:'HTTP'})]: 1, + [when({protocol:'AMQP'})]: 2, + [when()]: 0, + })); + + t.deepEqual(output, [1, 2, 2, 0]); + }); + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e2bea8 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "match-when", + "version": "1.0.0", + "description": "match-when - Pattern matching for modern JavaScript", + "main": "match.js", + "scripts": { + "test": "mocha *.test.js", + "test-watch": "mocha -w *.test.js" + }, + "author": "Francois-Guillaume Ribreau (http://fgribreau.com/)", + "license": "MIT", + "devDependencies": { + "chai": "^3.4.1", + "mocha": "^2.3.4" + } +} diff --git a/register/index.js b/register/index.js new file mode 100644 index 0000000..b5ae981 --- /dev/null +++ b/register/index.js @@ -0,0 +1,3 @@ +// const {match, when} = require('..'); +global.match = require('..').match; +global.when = require('..').when; diff --git a/register/register.test.js b/register/register.test.js new file mode 100644 index 0000000..d12ac61 --- /dev/null +++ b/register/register.test.js @@ -0,0 +1,11 @@ +'use strict'; + +require('./'); +const t = require('chai').assert; + +describe('match', () => { + it('should expose match and when globally', () => { + t.strictEqual(when, require('../').when); + t.strictEqual(match, require('../').match); + }) +})