diff --git a/.gitignore b/.gitignore index e69de29..cba87a3 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,2 @@ +/coverage/ +/node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0a50a3a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Note: __README.md__ is generated from comments in __index.js__. Do not modify +__README.md__ directly. + +1. Update local master branch: + + $ git checkout master + $ git pull upstream master + +2. Create feature branch: + + $ git checkout -b feature-x + +3. Make one or more atomic commits, and ensure that each commit has a + descriptive commit message. Commit messages should be line wrapped + at 72 characters. + +4. Run `make lint test`, and address any errors. Preferably, fix commits + in place using `git rebase` or `git commit --amend` to make the changes + easier to review. + +5. Push: + + $ git push origin feature-x + +6. Open a pull request. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e89bdfd --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) 2016 Sanctuary +Copyright (c) 2015 Simon Friis Vindum + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..38f5c42 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +ESLINT = node_modules/.bin/eslint --config node_modules/sanctuary-style/eslint-es6.json --env es6 +ISTANBUL = node_modules/.bin/istanbul +NPM = npm +TRANSCRIBE = node_modules/.bin/transcribe +XYZ = node_modules/.bin/xyz --repo git@github.com:sanctuary-js/sanctuary-union-type.git --script scripts/prepublish + + +.PHONY: all +all: LICENSE README.md + +.PHONY: LICENSE +LICENSE: + cp -- '$@' '$@.orig' + sed 's/Copyright (c) .* Sanctuary/Copyright (c) $(shell git log --date=format:%Y --pretty=format:%ad | sort -r | head -n 1) Sanctuary/' '$@.orig' >'$@' + rm -- '$@.orig' + +README.md: index.js + $(TRANSCRIBE) \ + --heading-level 4 \ + --url 'https://github.com/sanctuary-js/sanctuary-union-type/blob/v$(VERSION)/index.js#L{line}' \ + -- '$<' \ + | LC_ALL=C sed 's/

\(.*\)\1#\2/

\3\1#\2/' >'$@' + + +.PHONY: lint +lint: + $(ESLINT) --env node -- index.js + $(ESLINT) --env node --env mocha -- test/index.js + + +.PHONY: release-major release-minor release-patch +release-major release-minor release-patch: + @$(XYZ) --increment $(@:release-%=%) + + +.PHONY: setup +setup: + $(NPM) install + + +.PHONY: test +test: + $(ISTANBUL) cover node_modules/.bin/_mocha -- --ui tdd -- test/index.js + $(ISTANBUL) check-coverage --branches 100 diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..77380c5 --- /dev/null +++ b/circle.yml @@ -0,0 +1,12 @@ +dependencies: + override: + - printf '%s\n' color=false progress=false >.npmrc + - make setup + +machine: + node: + version: 6.1.0 + +test: + override: + - make lint test diff --git a/index.js b/index.js new file mode 100644 index 0000000..45d7e6e --- /dev/null +++ b/index.js @@ -0,0 +1,133 @@ +'use strict'; + +const $ = require('sanctuary-def'); +const Z = require('sanctuary-type-classes'); + + +// stripNamespace :: String -> String +const stripNamespace = s => s.slice(s.indexOf('/') + 1); + +// values :: Any -> Array a +const values = o => + Array.isArray(o) ? o : Z.map(k => o[k], Object.keys(o)); + +// zipObj :: (Array String, Array a) -> StrMap a +const zipObj = (ks, vs) => + ks.reduce((acc, k, idx) => { acc[k] = vs[idx]; return acc; }, {}); + +const a = $.TypeVariable('a'); + +const createIterator = function() { + return { + idx: 0, + val: this, + next() { + const keys = this.val._keys; + return this.idx === keys.length ? + {done: true} : + // eslint-disable-next-line no-plusplus + {value: this.val[keys[this.idx++]]}; + }, + }; +}; + + +module.exports = opts => { + + const def = $.create(opts); + + const CreateUnionType = (typeName, _cases, _prototype) => { + const unprefixedTypeName = stripNamespace(typeName); + // Type :: Type + const Type = $.NullaryType( + typeName, + x => x != null && x['@@type'] === typeName + ); + const env = Z.concat(opts.env, [Type]); + const def = $.create({checkTypes: opts.checkTypes, env}); + const cases = + Z.map(xs => Z.map(x => x === undefined ? Type : x, xs), + _cases); + const CaseRecordType = + $.RecordType(Z.map(x => $.Function(Z.concat(values(x), [a])), cases)); + + const prototype$case = function(cases) { + return '_' in cases ? + cases._(this) : + def( + `${unprefixedTypeName}::case`, + {}, + [CaseRecordType, a], + cases => cases[this._name](...Z.map(k => this[k], this._keys)) + )(cases); + }; + + Type.prototype = Object.assign(_prototype, {case: prototype$case}); + + Type.case = function(o, ..._args) { + return def( + `${unprefixedTypeName}.case`, + {}, + ['_' in o ? $.Any : CaseRecordType, Type, a], + (cases, value) => + value._name in cases ? + cases[value._name](...Z.map(k => value[k], value._keys)) : + cases._(value) + ).apply(this, Z.concat([o], _args)); + }; + + Object.keys(cases).forEach(name => { + const type = cases[name]; + const isArray = Array.isArray(type); + const keys = Object.keys(type); + const types = isArray ? type : values(type); + const recordType = $.RecordType(isArray ? zipObj(keys, types) : type); + + const of = + def(`${unprefixedTypeName}.${name}Of`, + {}, + [recordType, recordType], + r => { + const prototype = Object.create(_prototype); + prototype._keys = keys; + prototype._name = name; + prototype['@@type'] = typeName; + prototype[Symbol.iterator] = createIterator; + Object.keys(r).forEach(k => { prototype[k] = r[k]; }); + return prototype; + }); + + const ctor = + def(`${unprefixedTypeName}.${name}`, + {}, + Z.concat(types, [recordType]), + (...args) => of(zipObj(keys, args))); + + Type[`${name}Of`] = of; + Type[name] = ctor; + }); + + return Type; + }; + + const Named = + def('UnionType.Named', + {}, + [$.String, $.StrMap($.Any), $.Any], + (typeName, _cases) => CreateUnionType(typeName, _cases, {})); + + const Anonymous = + def('UnionType.Anonymous', + {}, + [$.StrMap($.Any), $.Any], + enums => + CreateUnionType(`(${Object.keys(enums).join(' | ')})`, enums, {})); + + const Class = + def('UnionType.Class', + {}, + [$.String, $.StrMap($.Any), $.Object, $.Any], + CreateUnionType); + + return {Anonymous, Named, Class}; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddf9d4e --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "sanctuary-union-type", + "version": "0.0.0", + "description": "Descendant of paldepind/union-type with more descriptive error messages", + "license": "MIT", + "repository": { + "type": "git", + "url": "git://github.com/sanctuary-js/sanctuary-union-type.git" + }, + "scripts": { + "test": "make lint test" + }, + "dependencies": { + "sanctuary-def": "github:sanctuary-js/sanctuary-def", + "sanctuary-type-classes": "0.3.x" + }, + "devDependencies": { + "eslint": "2.9.x", + "istanbul": "0.4.x", + "mocha": "3.x.x", + "ramda": "0.22.x", + "sanctuary-style": "0.3.x", + "transcribe": "0.5.x", + "xyz": "1.1.x" + }, + "files": [ + "/LICENSE", + "/README.md", + "/index.js", + "/package.json" + ] +} diff --git a/scripts/prepublish b/scripts/prepublish new file mode 100755 index 0000000..567df7d --- /dev/null +++ b/scripts/prepublish @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +rm -f README.md +make +git add LICENSE README.md diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..7796d54 --- /dev/null +++ b/test/index.js @@ -0,0 +1,240 @@ +'use strict'; + +const assert = require('assert'); + +const R = require('ramda'); +const $ = require('sanctuary-def'); +const Z = require('sanctuary-type-classes'); + +const UnionType = require('..'); + + +const a = $.TypeVariable('a'); + +const UT = UnionType({checkTypes: true, env: $.env}); + +const Type = UT.Anonymous; +const Named = UT.Named; +const Class = UT.Class; + +// eq :: (a, b) -> Undefined ! +const eq = (...args) => { + assert.strictEqual(args.length, 2); + const [actual, expected] = args; + assert.strictEqual(Z.toString(actual), Z.toString(expected)); + assert.strictEqual(Z.equals(actual, expected), true); +}; + +// throws :: (Function, TypeRep a, String) -> Undefined ! +const throws = (...args) => { + assert.strictEqual(args.length, 3); + const [f, type, message] = args; + assert.throws(f, err => err.constructor === type && err.message === message); +}; + + +test('defining a union type with predicates', () => { + const Num = $.NullaryType('my-package/Num', x => typeof x === 'number'); + const Point = Type({Point: [Num, Num]}); + const p = Point.Point(2, 3); + const [x, y] = p; + + eq([x, y], [2, 3]); +}); + +test('defining a union type with built ins', () => { + const I = R.identity; + [ + [2, $.Number, I], + ['2', $.String, I], + [true, $.Boolean, I], + [{a: 1}, $.Object, I], + [[0, 1, 2], $.Array($.Any), I], + [() => 1, $.AnyFunction, f => f()], + ] + .forEach( + ([expected, $, f]) => { + const Class = Type({$: [$]}); + const instance = Class.$(expected); + const actual = instance[0]; + + eq(f(actual), f(expected)); + } + ); +}); + +test('defining a record type', () => { + const Point = Type({Point: {x: $.Number, y: $.Number}}); + const [x, y] = Point.Point(2, 3); + const [x1, y1] = Point.PointOf({x: 2, y: 3}); + + eq([x, y], [x1, y1]); +}); + +test('create instance methods', () => { + const Maybe = Type({Just: [$.Any], Nothing: []}); + + const Nothing = Maybe.Nothing(); + const Just = Maybe.Just; + + Maybe.prototype['fantasy-land/equals'] = function(other) { + return this._name === 'Nothing' ? + other._name === 'Nothing' : + other._name === 'Just' && Z.equals(other[0], this[0]); + }; + + Maybe.prototype.map = function(f) { + return Maybe.case({Nothing: R.always(Nothing), Just: R.compose(Just, f)}, + this); + }; + + eq(Nothing.map(Math.sqrt), Nothing); + eq(Just(9).map(Math.sqrt), Just(3)); +}); + +test('create instance methods declaratively', () => { + const Maybe = Class('my-package/Maybe', {Just: [a], Nothing: []}, { + 'fantasy-land/equals'(other) { + return this._name === 'Nothing' ? + other._name === 'Nothing' : + other._name === 'Just' && Z.equals(other[0], this[0]); + }, + map(f) { + return Maybe.case({Nothing: R.always(Nothing), Just: R.compose(Just, f)}, + this); + }, + }); + + const Nothing = Maybe.Nothing(); + const Just = Maybe.Just; + + eq(Nothing.map(Math.sqrt), Nothing); + eq(Just(9).map(Math.sqrt), Just(3)); +}); + +test('Fields can be described in terms of other types', () => { + const Point = Type({Point: {x: $.Number, y: $.Number}}); + + const Shape = Type({ + Circle: [$.Number, Point], + Rectangle: [Point, Point], + }); + + const [radius, [x, y]] = Shape.Circle(4, Point.Point(2, 3)); + + eq([radius, x, y], [4, 2, 3]); +}); + +test('The values of a type can also have no fields at all', () => { + const NotifySetting = Type({Mute: [], Vibrate: [], Sound: [$.Number]}); + + eq('Mute', NotifySetting.Mute()._name); +}); + +test('If a field value does not match the spec an error is thrown', () => { + const Point = Named('my-package/Point', {Point: {x: $.Number, y: $.Number}}); + + throws(() => { Point.Point(4, 'foo'); }, + TypeError, + 'Invalid value\n' + + '\n' + + 'Point.Point :: Number -> Number -> { x :: Number, y :: Number }\n' + + ' ^^^^^^\n' + + ' 1\n' + + '\n' + + '1) "foo" :: String\n' + + '\n' + + 'The value at position 1 is not a member of ‘Number’.\n'); +}); + +test('Switching on union types', () => { + const Action = Type({Up: [], Right: [], Down: [], Left: []}); + const player = {x: 0, y: 0}; + + const advancePlayer = (action, player) => + Action.case({ + Up: () => ({x: player.x, y: player.y - 1}), + Right: () => ({x: player.x + 1, y: player.y}), + Down: () => ({x: player.x, y: player.y + 1}), + Left: () => ({x: player.x - 1, y: player.y}), + }, action); + + eq(advancePlayer(Action.Up(), player), {x: 0, y: -1}); +}); + +test('Switch on union types point free', () => { + const Point = Type({Point: {x: $.Number, y: $.Number}}); + + const Shape = Type({ + Circle: [$.Number, Point], + Rectangle: [Point, Point], + }); + + const p1 = Point.PointOf({x: 0, y: 0}); + const p2 = Point.PointOf({x: 10, y: 10}); + + { + const area = Shape.case({ + Circle: (radius, _) => Math.PI * radius * radius, + Rectangle: (p1, p2) => (p2.x - p1.x) * (p2.y - p1.y), + }); + + eq(area(Shape.Rectangle(p1, p2)), 100); + } + + { + const area = Shape.Rectangle(p1, p2).case({ + Circle: (radius, _) => Math.PI * radius * radius, + Rectangle: (p1, p2) => (p2.x - p1.x) * (p2.y - p1.y), + }); + + eq(area, 100); + } +}); + +test('Destructuring assignment to extract values', () => { + const Point = Type({Point: {x: $.Number, y: $.Number}}); + const [x, y] = Point.PointOf({x: 0, y: 0}); + + eq({x, y}, {x: 0, y: 0}); +}); + +test('Recursive Union Types', () => { + const List = Type({Nil: [], Cons: [a, undefined]}); + + const toString = List.case({ + Cons: (head, tail) => `${head} : ${toString(tail)}`, + Nil: () => 'Nil', + }); + + const list = List.Cons(1, List.Cons(2, List.Cons(3, List.Nil()))); + + eq(toString(list), '1 : 2 : 3 : Nil'); +}); + +test('Disabling Type Checking', () => { + const Type = UnionType({checkTypes: false, env: $.env}).Anonymous; + const Point = Type({Point: {x: $.Number, y: $.Number}}); + const p = Point.Point('foo', 4); + + eq(p.x, 'foo'); +}); + +test('Use placeholder for cases without matches', () => { + const List = Type({Nil: [], Cons: [a, undefined]}); + + eq(List.case({Cons: () => 'Cons', _: () => 'Nil'}, List.Nil()), 'Nil'); + eq(List.Nil().case({Cons: () => 'Cons', _: () => 'Nil'}), 'Nil'); +}); + +test('Create a Type with no cases', () => { + Type({}); +}); + +test('Can iterate through a instance\'s values', () => { + const T = Type({Values: [$.Number, $.Number, $.Number]}); + const instance = T.Values(1, 2, 3); + const results = []; + for (const x of instance) results.push(x); + eq(results, [1, 2, 3]); +});