pat-mat v0.1.1
Pat-Mat
A full-feature pattern matching library for JavaScript and CoffeeScript
Feature
- Plain Old JavaScript Object as Pattern
- Variable Binding
- Case Class
- Pattern Guard
- Alternative Pattern
- Customizable Extractor
- Concise API
- Automatic Class Annotator
- Enumeration Order Independent
Reason
There are pretty much pattern matching libraries existing. However, few of them are feature rich. Even though some libraries are powerful, they are either deprecated or require advanced macro system.
This repository, highly inspired by Scala, aims at creating a feature-rich pattern matching library while keeping every thing like plain old JavaScript. Being more powerful and concise is this library's Raison d'être.
Pat-Mat itself was written in CoffeeScript so all the example are also presented in that language. Pat-Mat looks better with Coffee's DSL extensibility. If you don't bother brew a jar of coffee, just add curly braces, return and etc. to make it work.
Install
Assuming you have installed npm and NodeJS. Then in your console.
npm install pat-mat
And in code:
# import
{Match, Is, parameter, paramSeq} = require('pat-mat')
# rename for eye candy
$ = parameter
$$ = paramSeq
# later use, (username)@(domain).xx
MAIL_REG = /(.*?)@(.*?)\..+/
# example usage
m = Match(
# literal
Is 42, -> 'meaning of life'
# Type
Is Function, -> 'get a function'
# object
Is {x: 3, y: 4}, -> 'x is 3, y is 4'
# alternative
Is Number, Boolean, -> 'num or func'
# variable binding
Is [$, $$], (head, tail) -> head
# RegExp
Is MAIL_REG, (s, name, domain) -> name
)
# match element by calling
m(42) # 'meaning of life'
# Match() returns a function
m(m) # 'get a function'And all patterns are just POJO -- plain old javascript objects, rather than string pattern. So you still have syntax highlight in your patterns.
Basic
Starting pattern match is just calling pat-mat's Match function. It receive several case expression as arguments and return a function that takes element to match. Case expression is the result of Is function. Is takes at least two arguments: the last one is a function to be called when a match is found, and other arguments before it are patterns.
Case expressions (and Patterns) are sequentially matched from top to down as passed when calling Match. The first matching pattern will trigger the matched function and pass matched variable to the latter.
Pat-Mat provides a parameter singleton for variable binding. If parameter occurs in pattern, it will be recorded in Is expression and be passed to matched function as argument.
Matched function's return value will be the result of pattern-matching, make sure you do return. Matched function will be passed arguments of variable length, depending on the matching pattern. Arguments order is generally left to right, top to down, but that's not guaranteed for object because ECMA's spec. Solution for this will be presented later.
NB: Pat-Mat also provides other case expression for different variable binding policies).
For now, if it is unclear for you, just reading the following example to see how to use Pat-Mat
{Match, Is, parameter} = require('pat-mat')
# finding the factorial of n
fact = Match(
Is 0, -> 1
Is parameter, (n) -> n * fact(n-1)
)
# fact is a function
fact(3) # is 6
fact(6) # is 720To summarize, there are just three points to leverage the basic of Pat-Mat
Match, call it and pass it severalIs, case expressions to match. EveryIshas- patterns and matched action. Matched action is just function
And how patterns are composed is just explained in the following section.
Patterns
Literals
Check value literally. It supports all JavaScript primitive values including string, number, null, undefined and NaN
Note that since patterns are just normal JavaScript objects, variables in patterns are passed as their value/reference.
k = 'a string'
patmat = Match(
Is 42, -> ...
Is 'a string', ->
Is k, -> ... # same as above
Is null, -> ...
Is undefined, -> ...
Is NaN, -> ... # matched by isNaN
)Parameter
Variables can be captured by using parameter. They are passed as arguments to matched actions.
# for shorter name
$ = parameter
patmat = Match(
Is [$, 2, 3], (p) -> 'p is ' + p
Is {x: $}, (x) -> 'x is ' + x
)
patmat([1, 2, 3]) # p is 1
patmat({x: 1}) # x is 1You can also use parameter as function to specify what kind of value will be captured.
parameter takes pattern as argument, except for string pattern.
_ = require('pat-mat').wildcard
patmat = Match(
Is [$(Number), 2, 3], -> 'matched'
Is _, -> 'no match'
)
patmat([1, 2, 3])
# matched
patmat(['str', 2, 3])
# no matchIf you worry that enumerating order of object keys is not stable, as specified by ECMA, you can use string to name the parameter.
And you need another function to generate case expression: On.
Matched action in On expression receives an plain object as argument, in which the names you assign to parameters are keys.
The second argument in parameter is pattern.
_ = require('pat-mat').wildcard
matchPoint = Match(
On {x: $('x', Number), $('y')}, (point) -> p.x + p.y
On _, -> 'not a point'
)
matchPoint({x: 3, y: 4}) # 7
matchPoint({x: '3' , y: 4}) # not a pointWildcard
Match will throws an NoMatchError if no CaseExpression fits the element.
You can use a wildcard pattern as the default case. Wildcard can also be nested pattern.
_ = require('pat-mat').wildcard
patmat = Match(
Is [_, _], -> 'two element array as tuple'
Is _, -> 'anything else'
)
patmat([2, 3]) # two element array as tuple
patmat(Array) # anything elseArray
Matches on entire array or pick up a few elements.
Pat-Mat provides paramSeq and wildcardSeq for matching subarray.
(Seq stands for sequence)
$ = require('pat-mat').parameter
$$ = require('pat-mat').paramSeq
sum = Match(
# $$ captures the subarray
Is [$, $$], (head, tail)-> head + sum(tail)
Is [], -> 0
)
sum([1, 2, 3]) # 6Just like wildcard, wildcardSeq does not bind subarray to any variables.
One array pattern can have one and only one sequence pattern. Otherwise an Error will occurs.
Array pattern matches all Array Like(has length property and its elements can be accessed by index) elements. So you can, for example, pass arguments to pattern matcher.
Object
Object is matched by comparing key-value pairs, so here Duck Typing is conducted.
# will match as long as element has x and y property
matchPoint = Match(
Is {x: $, y: $}, (x, y) -> 'get point'
)
class Point
constructor: (@x, @y)
# take any type
matchPoint(new Point(3, 4)) # get point
# even the property is null or undefined
matchPoint({x: 'd', y: null}) # get point
# but not if it has no such key
matchPoint({x: 1}) # NoMatchErrorType
If the pattern is a function, then the function will be treated as a constructor function. The element matched against must be a subtype of that constructor.
class Animal
class Snake extends Animal
class Python extends Snake
class Naja extends Snake
class Frog extends Animal
findAnimal = Match(
Is Python, -> 'large snake'
Is Snake, -> 'snake'
Is Animal, -> 'new species'
)
findAnimal(new Python) # 'large snake'
findAnimal(new Naja) # 'snake'
findAnimal(new Frog) # 'new species'NB:
instanceofis used as subtype checking. Comparingelement.constructorwill violates Liskov Substitution Principle
For core JavaScript datatype Number, String, Boolean, their corresponding primitive values are taken as matching elements.
# monoid like mappend
append = (a, b) -> Match(
Is [String, String], -> a + b
Is [Number, Number], -> a + b
Is [Array, Array], -> a.concat(b)
)(arguments)NB: Only in
Incase expression isTypepattern captured.
Regular Expression
Regular Expression is matched against element. If a match is found, the match and its capturing group will be passed to the matched action.
MAIL_REG = /(.*?)@(.*?)\..*/
mail = Match(
Is MAIL_REG, (_, name, domain) -> {name, domain}
Is _, -> 'no match'
)
mail('test@mail.com') # {name: 'test', domain: 'mail'}NB: Regular Expression is only captured in
Is.
Case Class
Pat-mat mocks Scala's case class by the function extract.
Case classes are regular classes which exports their constructor parameters and which provide a recursive decomposition mechanism via pattern matching.(source)
Applying extract to constructor function will return a equivalent constructor function that also doubles as case class pattern.
With new, extracted function returns a new instance; without new, extracted function returns a case class pattern.
Here is an example. This example uses Coffee's class syntax which is a natural fit for Case Class. (That's why Pat-Mat was written in Coffee)
Point = extract class Point
constructor: (@x, @y) ->
# more code
takeY = Match(
Is Point(3, $), (y) -> y
Is _, -> 'no match'
)
# a new Point instance
takeY(new Point(3, 4)) # 4
# a pattern instance
takeY(Point(3, 4)) # no match
# because x fails to match
takeY(new Point(4, 4)) # no matchIf you are using JavaScript:
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype = {
// more code
}
var Point = extract(Point);
var takeY = Match(
Is(Point(3, $), function(y) {
return y;
}),
Is(_, function() {
return 'no match';
})
);
takeY(new Point(3, 4)); // 4
takeY(Point(3, 4)); // no matchBy default, Pat-Mat tries to annotate the constructor and to retrieve its parameter name.
If the element to be matched is an instance of the constructor, then the element's fields with same names with parameter will be recursively matched against the pattern in the case class pattern. So the pattern Point(3, $) matches element p if p.x == 3, and then pass p.y to the action as argument if matched.
However, automatic annotation does not work in compressed JavaScript, and fails to match if the fields in constructor are modified during initialization. For solution, please refer to Class Annotator section.
Case Expression
Is/As/On
Object in JavaScript is a collection of unordered key-value pair. Though expressions like {x: $, y: $} will usually keep the order, Pat-Mat gives different case expression function to guarantee order.
As will apply arguments to the matched action, but variable binding needs to call parameter in pattern.
argCount = -> arguments.length
m = Match(
# no captured group
As /test/ , argCount
# function constructor will not be captured
As String , argCount
# will be captured because it invokes `parameter`
As {x: $()} , argCount
# only capture the first element
As [$(), __ , Number] , argCount
# `parameter` itself is not captured
As $, argCount
)
m('test') # 0
m('ssss') # 0
m({x: 5, y: 5}) # 1
m([3, 3, 3]) # 1
m(null) # 0On will pass an object of which the values are captured variables .On requires NamedParameter, which means parameter should be invoked with a name string as its first argument. The name of parameter will be the key of the object.
m = Match(
On $('n', Number), (m) -> m.n * 2
On {x: $('x'), y: $('y')}, (m) -> m.x + m.y
On $(), -> @unnamed[0]
)
m(2) # 4
m({x: 5, y: 5}) # 10
m(true) # trueUninvoked parameter in As and On, and parameter instance other than NamedParameter will be stored in this.unnamed array and binded to matched function.
NB:
Isstands for incremental.Asstands for Array.Onstands for Object. The initials of these functions suggests their argument passing policies.
Matched Action
Matched action is just plain function. How it receives arguments is dependent on the case expression, as specified before.
Matched action has binded to matching objects to pass more information. You can access the whole match via this.m and variables that are not captured by As/On via this.unnamed.
fib = Match(
As 0, -> 0
As 1, -> 1
As Number, -> fib(@m-1) + fib(@m-2)
)
fib(longProcess().getData().getMockNumber().canBeBindedToThisM())Class Annotator
extract is a function that returns a case class constructor.
It will analyzes the original constructor function by toString() and extracts the fields.
However, compressed JavaScript will lose the information. You can set the unapply static attribute of the constructor function to give Pat-Mat a hint.
unapply can be annotation, an array of string that corresponds to the constructor's argument and instance fields.
Point = extract class Point
constructor: (longlongx, longlongy) ->
@x = longlongx
@y = longlongy
@unapply = ['x', 'y']
p = new Point(3, 4)
# now Pat-Mat will compare p.x and p.y
# Point(3, 4) will match pIf the fields are modified in constructor, you can set unapply to a transform function.
transform function takes the element to be matched as argument, and should return an objects with properties specified in annotation.
annotation is just the array described above. If unapply is function, annotation is programatically found.
UnitVector = extract class UnitVector
constructor: (x, y) ->
norm = Math.sqrt(x*x + y*y)
@x = x / norm
@y = y / norm
@unapply = (other) ->
x = other.x
y = other.y
norm = Math.sqrt(x*x + y*y)
# in this case you can also return
# new UnitVector(other.x, other.y)
# because the constructor is side-effect free
return {
x: x / norm
y: y / norm
}Combining annotation and transform is okay.
Set unapply to an object with transform and annotation.
Circle = extract class Circle
constructor: (longlongr) ->
@r = longlongr
@unapply = {
annotation: ['r']
transform: Match(
# only transform Circle/Point instance
Is Circle, -> @m
Is Point($, $), (x, y) ->
{r: Math.sqrt(x*x + y*y)}
Is _, -> null
)
}
getRadius = Match(
Is Circle($), (r) -> 'radius: ' + r
)
getRadius(new Circle(5)) # radius: 5
getRadius(new Point(3, 4)) # radius: 5
getRadius({r: 5}) # throw NoMatchErrorIf transform is defined, then the case class pattern can match any type, as long as the transform's return value is not null.
As illustrated above, transform can be implemented easily with Pat-Mat.
Customized Extractor
Much similar to class annotator, customized extractor is constructed by passing an unapply object to extract.
unapply should have annotation property and optional transform property.
Attention: extractor is not a constructor function.
Circle = extract({
annotation: ['r']
transform: Match(
Is {r: Number}, -> @m # duck typing
Is Point($, $), (x, y) ->
{r: Math.sqrt(x*x + y*y)}
Is _, -> null
)
})
getRadius = Match(
Is Circle($), (r) -> 'radius: ' + r
)
getRadius(new Point(3, 4)) # radius: 5
getRadius({r: 5}) # radius: 5
getRadius(new Circle(5)) # TypeError, Circle is not a constructor functionPattern Guard
Pattern guard is also supported by guard function.
Pattern guard should immediately follow the pattern in case expression. Only one pattern can precede the guard, so no alternative pattern cannot be used.
m = Match(
Is Number, guard(-> @m%2 == 0), -> 'even'
Is Number, guard(-> @m%2 == 1), -> 'odd'
Is wildcard, -> 'not integer'
)
m(2) # is 'even'
m(3) # is 'odd'
m('dd') # is 'not integer'API
Start Match
Match(CaseExpressions...) -> Function
Take serveral CaseExpressions as arguments and return a function that matches element.
If one argument is not CaseExpression, then a TypeError is thrown.
If no CaseExpression is matched, then an NoMatchError is thrown.
Generate CaseExpression
Is(Patterns..., Function) -> CaseExpression
Is(Pattern, Guard, Function) -> CaseExpression
The last argument should be a function for matched action. Is feeds captured variables to matched action as arguments sequentially.
Is also captures Constructor pattern and RegExp pattern.
And the whole matching element is binded to this keyword, you can access by this.m in the function.
As(Patterns..., Function) -> CaseExpression
As(Pattern, Guard, Function) -> CaseExpression
The last argument should be a function for matched action. As only captures patterns that is generated by calling parameter.
So As does not capture Constructor pattern and RegExp pattern.
And the whole matching element is binded to this keyword, you can access by this.m in the function.
If parameter occurs in patterns that is not called, they can be accessed by this.unnamed array in the function.
On(Patterns..., Function) -> CaseExpression
On(Pattern, Guard, Function) -> CaseExpression
The last argument should be a function for matched action. On only captures patterns that is named parameter like $('name', Pattern)
On does not capture Constructor pattern and RegExp pattern.
And the whole matching element is binded to this keyword, you can access by this.m in the function.
If parameter is not named, they can be accessed by this.unnamed array in the function.