From 6900f1a4f50595f41c1d33d4337bbb941937b5f9 Mon Sep 17 00:00:00 2001 From: paldepind Date: Sat, 20 May 2017 17:26:31 +0200 Subject: [PATCH] Initial commit. Setup config and copy paste from Jabz --- .gitignore | 14 +++ .travis.yml | 7 ++ .vscode/launch.json | 36 +++++++ LICENSE | 21 ++++ package.json | 81 ++++++++++++++++ src/freer.ts | 60 ++++++++++++ src/index.ts | 153 +++++++++++++++++++++++++++++ test/index.ts | 220 ++++++++++++++++++++++++++++++++++++++++++ tsconfig-release.json | 24 +++++ tsconfig.json | 13 +++ tslint.json | 127 ++++++++++++++++++++++++ 11 files changed, 756 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 .vscode/launch.json create mode 100644 LICENSE create mode 100644 package.json create mode 100644 src/freer.ts create mode 100644 src/index.ts create mode 100644 test/index.ts create mode 100644 tsconfig-release.json create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d711717 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.log +bundle.js +*.map +node_modules/ +*~ +typings +.tern-port +.#* +dist/ +benchmark/hareactive-old +.nyc_output/ +coverage/ +src/**/*.js +test/**/*.js \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..39e7f51 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - "6" + - "6.1" + +after_success: + - "npm run codecov" \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3740f71 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + // This configurtion makes it possible to debug the Mocha tests with + // break points. + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Mocha Tests 2", + "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", + "protocol": "inspector", + "args": [ + "--no-timeouts", + "-r", + "ts-node/register", + "--colors", + "${workspaceRoot}/test/**/*.ts" + ], + "outFiles": [ + "${workspaceRoot}/dist" + ], + "sourceMaps": true, + "cwd": "${workspaceRoot}", + "runtimeExecutable": null, + "internalConsoleOptions": "openOnSessionStart", + "stopOnEntry": false, + "env": { + "NODE_ENV": "testing" + }, + "skipFiles": [ + "node_modules/**/*.js", + "/**/*.js" + ] + }, + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a52e558 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 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/package.json b/package.json new file mode 100644 index 0000000..b20251b --- /dev/null +++ b/package.json @@ -0,0 +1,81 @@ +{ + "name": "@funkia/io", + "version": "0.0.1", + "description": "A library that turns impure code pure.", + "main": "dist/index.js", + "module": "dist/es/index.js", + "types": "dist/defs/index.d.ts", + "directories": { + "test": "test", + "dist": "dist" + }, + "scripts": { + "build": "npm run build-es6; npm run build-cmjs", + "build-es6": "tsc -P ./tsconfig-release.json --outDir 'dist/es' --target es2015 --module es2015", + "build-cmjs": "tsc -P ./tsconfig-release.json", + "prepare": "npm run clean; npm run build", + "clean": "rm -rf dist coverage .nyc_output", + "test": "nyc mocha --recursive test/**/*.ts", + "test-watch": "mocha -R progress --watch --compilers ts:ts-node/register test/**/*.ts", + "codecov": "codecov -f coverage/coverage-final.json", + "release-major": "xyz --repo git@github.com:funkia/io.git --increment major", + "release-minor": "xyz --repo git@github.com:funkia/io.git --increment minor", + "release-patch": "xyz --repo git@github.com:funkia/io.git --increment patch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/funkia/io.git" + }, + "keywords": [ + "frp", + "functional reactive programming", + "pure", + "funkia" + ], + "author": "Funkia", + "license": "MIT", + "bugs": { + "url": "https://github.com/funkia/io/issues" + }, + "homepage": "https://github.com/funkia/io#readme", + "dependencies": { + "@funkia/jabz": "0.0.21", + "tslib": "^1.7.1" + }, + "devDependencies": { + "@types/benchmark": "^1.0.30", + "@types/chai": "^3.5.2", + "@types/mocha": "^2.2.41", + "@types/sinon": "^1.16.36", + "benchmark": "^2.1.4", + "browser-env": "^2.0.31", + "browserify": "^14.3.0", + "browserify-istanbul": "^2.0.0", + "chai": "^3.5.0", + "codecov": "^2.1.0", + "mocha": "^3.3.0", + "nyc": "^10.3.2", + "sinon": "^2.2.0", + "source-map-support": "^0.4.15", + "ts-node": "^3.0.3", + "tsify": "^3.0.1", + "typescript": "^2.3.2", + "watchify": "^3.9.0", + "webpack": "^2.5.0", + "xyz": "2.1.0" + }, + "nyc": { + "extension": [ + ".ts" + ], + "require": [ + "ts-node/register", + "source-map-support/register" + ], + "reporter": [ + "json", + "html", + "text" + ] + } +} diff --git a/src/freer.ts b/src/freer.ts new file mode 100644 index 0000000..62cfef1 --- /dev/null +++ b/src/freer.ts @@ -0,0 +1,60 @@ +import {AbstractMonad, Monad, monad} from "@funkia/jabz"; + +export type FreerMatch = { + pure: (a: A) => K + bind: (u: F, k: (a: any) => Freer) => K +}; + +export abstract class Freer extends AbstractMonad { + static of(b: B): Freer { + return new Pure(b); + } + of(b: B): Freer { + return new Pure(b); + } + abstract match(m: FreerMatch): K; + abstract map(f: (a: A) => B): Freer; + multi: false; + static multi = false; + abstract chain(f: (a: A) => Freer): Freer; +} + +@monad +export class Pure extends Freer { + constructor(private a: A) { + super(); + } + match(m: FreerMatch): K { + return m.pure(this.a); + } + map(f: (a: A) => B): Freer { + return new Pure(f(this.a)); + } + chain(f: (a: A) => Freer): Freer { + return f(this.a); + } +} + +function pure(a: A): Freer { + return new Pure(a); +} + +@monad +export class Bind extends Freer { + constructor(public val: any, public f: (a: any) => Freer) { + super(); + } + match(m: FreerMatch): K { + return m.bind(this.val, this.f); + } + map(f: (a: A) => B): Freer { + return new Bind(this.val, (a: any) => this.f(a).map(f)); + } + chain(f: (a: A) => Freer): Freer { + return new Bind(this.val, (a: any) => this.f(a).chain(f)); + } +} + +export function liftF(fa: any): Freer { + return new Bind(fa, pure); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fc7bdbb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,153 @@ +import { Freer, liftF } from "./freer"; +import { Monad, AbstractMonad } from "@funkia/jabz"; + +function deepEqual(a: any, b: any): boolean { + if (typeof a === "object" && typeof b === "object") { + const aKeys = Object.keys(a); + for (const key of aKeys) { + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } else { + return a === b; + } +} + +export type F0 = + () => Z; +export type F1 = + (a: A) => Z; +export type F2 = + (a: A, b: B) => Z; +export type F3 = + (a: A, b: B, c: C) => Z; +export type F4 = + (a: A, b: B, c: C, d: D) => Z; +export type F5 = + (a: A, b: B, c: C, d: D, e: E) => Z; + +export type IOValue = Call | CallP | ThrowE | CatchE; + +export class Call { + type: "call" = "call"; + constructor(public fn: Function, public args: any[]) { } +} + +export class CallP { + type: "callP" = "callP"; + constructor(public fn: Function, public args: any[]) { } +} + +export class ThrowE { + type: "throwE" = "throwE"; + constructor(public error: any) { } +} + +export class CatchE { + type: "catchE" = "catchE"; + constructor(public handler: (error: any) => IO, public io: IO) { } +} + +export type IO = Freer, A>; + +export const IO = Freer; + +// in the IO monad +export function withEffects(f: F1): (a: A) => IO; +export function withEffects(f: F2): (a: A, b: B) => IO; +export function withEffects(f: F3): (a: A, b: B, c: C) => IO; +export function withEffects(f: F4): (a: A, b: B, c: C, d: D) => IO; +export function withEffects(f: F5): (a: A, b: B, c: C, d: D, e: E) => IO; +export function withEffects(fn: any): (...as: any[]) => IO { + return (...args: any[]) => liftF(new Call(fn, args)); +} + +export function withEffectsP(f: F1>): (a: A) => IO; +export function withEffectsP(f: F2>): (a: A, b: B) => IO; +export function withEffectsP(f: F3>): (a: A, b: B, c: C) => IO; +export function withEffectsP(f: F4>): (a: A, b: B, c: C, d: D) => IO; +export function withEffectsP(f: F5>): (a: A, b: B, c: C, d: D, e: E) => IO; +export function withEffectsP(fn: (...as: any[]) => Promise): (...a: any[]) => IO { + return (...args: any[]) => liftF(new CallP(fn, args)); +} + +export function call(f: F0): IO; +export function call(f: F1, a: A): IO; +export function call(f: F2, a: A, b: B): IO; +export function call(f: F3, a: A, b: B, c: C): IO; +export function call(f: F4, a: A, b: B, c: C, d: D): IO; +export function call(f: F5, a: A, b: B, c: C, d: D, e: E): IO; +export function call(fn: Function, ...args: any[]): IO { + return liftF(new Call(fn, args)); +} + +export function callP(f: F0): IO; +export function callP(f: F1>, a: A): IO; +export function callP(f: F2>, a: A, b: B): IO; +export function callP(f: F3>, a: A, b: B, c: C): IO; +export function callP(f: F4>, a: A, b: B, c: C, d: D): IO; +export function callP(f: F5>, a: A, b: B, c: C, d: D, e: E): IO; +export function callP(fn: Function, ...args: any[]): IO { + return liftF(new CallP(fn, args)); +} + +export function throwE(error: any): IO { + return liftF(new ThrowE(error)); +} + +export function catchE( + errorHandler: (error: any) => IO, io: IO +): IO { + return liftF(new CatchE(errorHandler, io)); +} + +export function doRunIO(e: IO): Promise { + return e.match>({ + pure: (a) => Promise.resolve(a), + bind: (io, cont) => { + switch (io.type) { + case "call": + return runIO(cont(io.fn(...io.args))); + case "callP": + return io.fn(...io.args) + .then((a: A) => runIO(cont(a))); + case "catchE": + return doRunIO(io.io) + .then((a: A) => runIO(cont(a))) + .catch((err: any) => doRunIO(io.handler(err))); + case "throwE": + return Promise.reject(io.error); + } + } + }); +} + +export function runIO(e: IO): Promise { + return doRunIO(e); +} + +function doTestIO(e: IO, arr: any[], ending: A, idx: number): void { + e.match({ + pure: (a2) => { + if (ending !== a2) { + throw new Error( + `Pure value invalid, expected ${ending} but saw ${a2}` + ); + } + }, + bind: (io, cont) => { + const [{ val: io2 }, a] = arr[idx]; + if (!deepEqual(io, io2)) { + throw new Error(`Value invalid, expected ${io2} but saw ${io}`); + } else { + doTestIO(cont(a), arr, ending, idx + 1); + } + } + }); +} + +export function testIO(e: IO, arr: any[], a: A): void { + doTestIO(e, arr, a, 0); +} diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..a585422 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,220 @@ +import { assert } from "chai"; +import { go, ap } from "@funkia/jabz"; + +import { + IO, runIO, testIO, withEffects, withEffectsP, call, callP, catchE, throwE +} from "../src/index"; + +function add(n: number, m: number) { + return n + m; +} + +describe("IO", () => { + it("gives pure computation", () => { + return runIO(IO.of(12)).then((res) => { + assert.equal(12, res); + }); + }); + describe("functor", () => { + it("maps pure computation", () => { + return runIO(IO.of(12).map((n) => n * n)).then((res) => { + assert.equal(144, res); + }); + }); + it("is stack safe", () => { + const amount = 10000; + let mapped = IO.of(0); + for (let i = 0; i < amount; ++i) { + mapped = mapped.map((n) => n + 1); + } + // return runIO(mapped).then((n) => assert.strictEqual(n, amount)); + }); + }); + it("chains computations", () => { + return runIO(IO.of(3).chain(n => IO.of(n + 4))).then((res) => { + assert.equal(7, res); + }); + }); + it("works with do-notation", () => { + const f1 = withEffects((a: number) => a * 2); + const f2 = withEffects((a: number, b: number) => a + b); + const comp: IO = go(function* () { + const a = yield IO.of(4); + const b = yield f1(3); + const sum = yield f2(a, b); + return sum; + }); + return runIO(comp).then((res) => { + assert.equal(10, res); + }); + }); + it("applies function in effects to value in other effects", () => { + const f1 = IO.of((a: number) => a * 2); + const f2 = IO.of(3); + const applied = ap(f1, f2); + return runIO(applied).then(res => assert.equal(res, 6)); + }); + describe("wrapping", () => { + it("wraps imperative function", () => { + let variable = 0; + function imperative(a: number, b: number): number { + variable = variable + a + b; + return variable; + } + const wrapped = withEffects(imperative); + const comp = go(function* () { + const a = yield wrapped(1, 2); + assert.strictEqual(variable, 3); + const b = yield wrapped(3, 4); + assert.strictEqual(variable, 10); + return a + b; + }); + return runIO(comp).then((res) => { + assert.strictEqual(res, 13); + }); + }); + it("wraps imperative function returning promise", () => { + let variable = 0; + function imperativeP(a: number, b: number): Promise { + variable = variable + a + b; + return Promise.resolve(variable); + } + const wrapped = withEffectsP(imperativeP); + const comp = go(function* () { + const a = yield wrapped(1, 2); + assert.strictEqual(a, 3); + assert.strictEqual(variable, 3); + const b = yield wrapped(3, 4); + assert.strictEqual(b, 10); + assert.strictEqual(variable, 10); + return add(a, b); + }); + return runIO(comp).then((res) => { + assert.deepEqual(res, 13); + }); + }); + }); + describe("error handling", () => { + const errorMessage = "I do not accept zero"; + it("can catch error from rejected promise", () => { + function imperativeP(a: number): Promise { + return a === 0 + ? Promise.reject(errorMessage) + : Promise.resolve(a); + } + const wrapped = withEffectsP(imperativeP); + const comp = catchE((err: string) => IO.of(err.length), wrapped(0)); + return runIO(comp).then((res) => { + assert.deepEqual(res, errorMessage.length); + return runIO(wrapped(0)); + }).catch((res) => { + assert.deepEqual(res, errorMessage); + }); + }); + it("`catchE` function is not called when no error", () => { + return runIO( + catchE((_: any) => { throw new Error("No"); }, IO.of(12)) + ).then((res) => { + assert.strictEqual(res, 12); + }); + }); + it("can throw error with `throwE`", () => { + const comp = catchE( + (err: string) => IO.of(err.length), + go(function* () { + const a = yield IO.of(13); + assert.deepEqual(a, 13); + const b = yield throwE(errorMessage); + return "Oh no, error thrown above >.<"; + }) + ); + return runIO(comp).then((res) => { + assert.deepEqual(res, errorMessage.length); + }); + }); + }); + describe("calling", () => { + it("calls function", () => { + let variable = 0; + function imperative( + a: number, b: number, c: number, d: number + ): number { + variable = a + b + c + d; + return variable; + } + return runIO(call(imperative, 1, 2, 3, 4)).then((res) => { + assert.strictEqual(variable, 10); + assert.strictEqual(res, 10); + }); + }); + it("calls promise returning function", () => { + let variable = 0; + function imperative(a: number, b: number): Promise { + variable = a + b; + return Promise.resolve(variable); + } + return runIO(callP(imperative, 1, 2)).then((res) => { + assert.deepEqual(variable, 3); + assert.deepEqual(res, 3); + }); + }); + it("calls promise returning function that rejects", () => { + let variable = 0; + function imperative(a: number, b: number): Promise { + variable = a + b; + return Promise.reject(variable); + } + return runIO(callP(imperative, 1, 2)).catch((res) => { + assert.deepEqual(variable, 3); + assert.deepEqual(res, 3); + }); + }); + }); + describe("testing", () => { + let mutableN = 0; + function add(m: number) { + return mutableN += m; + } + function addTwice(m: number) { + return mutableN += 2 * m; + } + const wrapped1 = withEffects(add); + const wrapped2 = withEffects(addTwice); + it("can test without running side-effects", () => { + const comp = wrapped1(2).chain((n) => wrapped2(3)); + testIO(comp, [ + [wrapped1(2), 2], + [wrapped2(3), 8] + ], 8); + assert.deepEqual(mutableN, 0); + }); + it("throws on incorrect function", () => { + const comp = wrapped1(2).chain((n) => wrapped2(3)); + assert.throws(() => { + const expected = [ + [call(wrapped2, 2), 2], + [call(wrapped2, 3), 8] + ]; + testIO(comp, expected, 8); + }); + }); + it("handles computation ending with `of`", () => { + const comp = wrapped1(3).chain((n) => IO.of(4)); + testIO(comp, [ + [wrapped1(3), 3] + ], 4); + assert.throws(() => { + testIO(comp, [ + [wrapped1(3), 3] + ], 5); + }); + }); + it("handles computation with map and chain", () => { + const comp2 = wrapped1(4).map((n) => n * n).chain((n) => wrapped2(n + 2)); + testIO(comp2, [ + [wrapped1(4), 4], + [wrapped2(18), 18 * 2 + 4] + ], 18 * 2 + 4); + }); + }); +}); diff --git a/tsconfig-release.json b/tsconfig-release.json new file mode 100644 index 0000000..b048515 --- /dev/null +++ b/tsconfig-release.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "declarationDir": "./dist/defs", + "outDir": "./dist", + "rootDir": "./src", + "noImplicitAny": false, + "sourceMap": true, + "experimentalDecorators": true, + "lib": ["dom", "es5", "es2015.core", "es2015.promise", "es2015.iterable"] + }, + "files": [ + "node_modules/@types/mocha/index.d.ts" + ], + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf3590d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "noImplicitAny": false, + "experimentalDecorators": true, + "importHelpers": true, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..20fced0 --- /dev/null +++ b/tslint.json @@ -0,0 +1,127 @@ +{ + "defaultSeverity": "warning", + "rules": { + "align": [ + true, + "parameters", + "statements" + ], + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "eofline": true, + "forin": true, + "indent": [ + true, + "spaces" + ], + "jsdoc-format": true, + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "new-parens": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-eval": true, + "no-inferrable-types": false, + "no-internal-module": true, + "no-null-keyword": true, + "no-reference": true, + "no-require-imports": true, + "no-shadowed-variable": true, + "no-string-literal": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-unused-variable": [true, {"ignore-pattern": "^_"}], + "no-var-keyword": true, + "no-var-requires": true, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-finally", + "check-whitespace" + ], + "one-variable-per-declaration": [ + true, + "ignore-for-loop" + ], + "quotemark": [ + true, + "double", + "avoid-escape" + ], + "radix": true, + "semicolon": [true, "always"], + "switch-default": true, + "trailing-comma": [ + true, + { + "multiline": "never", + "singleline": "never" + } + ], + "triple-equals": [ + true + ], + "typedef": [ + true, + "call-signature", + "parameter", + "property-declaration", + "member-variable-declaration" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "space", + "index-signature": "space", + "parameter": "space", + "property-declaration": "space", + "variable-declaration": "space" + } + ], + "use-isnan": true, + "variable-name": [ + true, + "check-format", + "allow-leading-underscore", + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +}