Skip to content

Commit

Permalink
feat(match-when): this is so much fun!
Browse files Browse the repository at this point in the history
  • Loading branch information
FGRibreau committed Dec 28, 2015
0 parents commit d988eea
Show file tree
Hide file tree
Showing 6 changed files with 344 additions and 0 deletions.
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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()`
111 changes: 111 additions & 0 deletions match.js
Original file line number Diff line number Diff line change
@@ -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
};
112 changes: 112 additions & 0 deletions match.test.js
Original file line number Diff line number Diff line change
@@ -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]);
});
});
});
16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 <npm@fgribreau.com> (http://fgribreau.com/)",
"license": "MIT",
"devDependencies": {
"chai": "^3.4.1",
"mocha": "^2.3.4"
}
}
3 changes: 3 additions & 0 deletions register/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// const {match, when} = require('..');
global.match = require('..').match;
global.when = require('..').when;
11 changes: 11 additions & 0 deletions register/register.test.js
Original file line number Diff line number Diff line change
@@ -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);
})
})

0 comments on commit d988eea

Please sign in to comment.