Repository: goldibex/targaryen
Pull request #83 (2016-11-13 15:34:53)

Add more debugging info
Print the each expression evaluation result; e.g., with chai:

    2) A valid set of security rules and data can have write errors:
       AssertionError: Expected a user authenticated via Password Login to be able to write true to users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent, but the rules denied the write.
        /users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3: write "auth !== null && root.child('users').child(auth.uid).child('king').val() === true"  => false
          (
            auth !== null  [=> true]
            && root.child("users").child(auth.uid).child("king").val() === true  [=> false]
          )  [=> false]
          using [
            auth = {"uid":"password:500f6e96-92c6-4f60-ad5d-207253aee4d3","id":1,"provider":"password"}
            auth.uid = "password:500f6e96-92c6-4f60-ad5d-207253aee4d3"
            root = {"path":"","exists":true}
            root.child('users') = {"path":"users","exists":true}
            root.child('users').child(auth.uid) = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3","exists":true}
            root.child('users').child(auth.uid).child('king') = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/king","exists":false}
            root.child('users').child(auth.uid).child('king').val() = null
          ]

        /users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent: validate "data.parent().child('on-fire').val() === false"  => false
          data.parent().child("on-fire").val() === false  [=> false]
          using [
            data = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent","exists":false}
            data.parent() = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3","exists":true}
            data.parent().child('on-fire') = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/on-fire","exists":false}
            data.parent().child('on-fire').val() = null
          ]

        No .write rule allowed the operation.
        One or more .validate rules disallowed the operation.
        write was denied.
        at Assertion.<anonymous> (plugins/chai.js:98:12)
        at Assertion.ctx.(anonymous function) [as path] (node_modules/chai/lib/chai/utils/addMethod.js:41:25)
        at Context.<anonymous> (docs/chai/examples/failing.js:25:9)


Patch:
diff --git a/docs/chai/README.md b/docs/chai/README.md
index 398798f..7842889 100644
--- a/docs/chai/README.md
+++ b/docs/chai/README.md
@@ -53,6 +53,8 @@ mocha examples/<name of example>.js
 - `chaiTargaryen.chai`: The plugin object. Load this using `chai.use(chaiTargaryen.chai)` before running any tests.
 - `chaiTargaryen.setFirebaseData(data)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`.
 - `chaiTargaryen.setFirebaseRules(rules)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules.
+- `chaiTargaryen.setDebug(flag)`: Failed expectations will show the result of each rule when debug is set to `true` (`true` by default).
+- `chaiTargaryen.setVerbose(flag)`: Failed expectations will show the detailed evaluation of each rule when verbose is set to `true`(`true` by default).
 - `chaiTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys:
   - `unauthenticated`: an unauthenticated user, i.e., `auth === null`.
   - `anonymous`: a user authenticated using Firebase anonymous sessions.
diff --git a/docs/chai/examples/rules.json b/docs/chai/examples/rules.json
index 9a5e6b5..8ca79cb 100644
--- a/docs/chai/examples/rules.json
+++ b/docs/chai/examples/rules.json
@@ -6,7 +6,7 @@
           ".validate": "data.parent().child('on-fire').val() === false"
         },
         ".read": "auth !== null",
-        ".write": "root.child('users').child(auth.uid).child('king').val() === true"
+        ".write": "auth !== null && root.child('users').child(auth.uid).child('king').val() === true"
       }
     }
   }
diff --git a/docs/jasmine/README.md b/docs/jasmine/README.md
index aff8130..08b8cf2 100644
--- a/docs/jasmine/README.md
+++ b/docs/jasmine/README.md
@@ -52,6 +52,8 @@ jasmine spec/security/<name of example>.js
 - `jasmineTargaryen.matchers`: The plugin object. Load this using `jasmine.addMatchers(jasmineTargaryen.matchers)` before running any tests.
 - `jasmineTargaryen.setFirebaseData(data)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`.
 - `jasmineTargaryen.setFirebaseRules(rules)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules.
+- `jasmineTargaryen.setDebug(flag)`: Failed expectations will show the result of each rule when debug is set to `true` (`true` by default).
+- `jasmineTargaryen.setVerbose(flag)`: Failed expectations will show the detailed evaluation of each rule when verbose is set to `true`(`true` by default).
 - `jasmineTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys:
   - `unauthenticated`: an unauthenticated user, i.e., `auth === null`.
   - `anonymous`: a user authenticated using Firebase anonymous sessions.
diff --git a/docs/jasmine/examples/spec/security/erroring.js b/docs/jasmine/examples/spec/security/erroring.js
index 7b49184..928b5d1 100644
--- a/docs/jasmine/examples/spec/security/erroring.js
+++ b/docs/jasmine/examples/spec/security/erroring.js
@@ -1,7 +1,7 @@
 'use strict';
 
 // in your app this would be require('targaryen/plugins/jasmine')
-const targaryen = require('../../../plugins/jasmine');
+const targaryen = require('../../../../../plugins/jasmine');
 
 describe('An invalid set of security rules and data', function() {
 
diff --git a/docs/jasmine/examples/spec/security/failing.js b/docs/jasmine/examples/spec/security/failing.js
index 20e21e4..3a14472 100644
--- a/docs/jasmine/examples/spec/security/failing.js
+++ b/docs/jasmine/examples/spec/security/failing.js
@@ -1,7 +1,7 @@
 'use strict';
 
 // in your app this would be require('targaryen/plugins/jasmine')
-const targaryen = require('../../../plugins/jasmine');
+const targaryen = require('../../../../../plugins/jasmine');
 const users = targaryen.users;
 
 targaryen.setFirebaseData(require('./data.json'));
diff --git a/docs/jasmine/examples/spec/security/passing.js b/docs/jasmine/examples/spec/security/passing.js
index c8e29a5..fc1b6fd 100644
--- a/docs/jasmine/examples/spec/security/passing.js
+++ b/docs/jasmine/examples/spec/security/passing.js
@@ -2,7 +2,7 @@
 'use strict';
 
 // in your app this would be require('targaryen/plugins/jasmine')
-const targaryen = require('../../../plugins/jasmine');
+const targaryen = require('../../../../../plugins/jasmine');
 const users = targaryen.users;
 
 targaryen.setFirebaseData(require('./data.json'));
diff --git a/lib/database.js b/lib/database.js
index 1dbfae4..8db74a7 100644
--- a/lib/database.js
+++ b/lib/database.js
@@ -13,6 +13,14 @@ const results = require('./results');
 
 /**
  * Hold the data and the timestamp of its last update.
+ *
+ * @typedef {{
+ *          rules: {rules: object},
+ *          data: any,
+ *          auth: any,
+ *          now: number,
+ *          debug: boolean
+ *        }} DatabaseOptions
  */
 class Database {
 
@@ -23,38 +31,36 @@ class Database {
    *
    * It returns an immutable object.
    *
-   * @param  {{rules: object}} rules Firebase rule definition.
-   * @param  {any}             data The database data.
-   * @param  {object|null}     auth  Current user auth data.
-   * @param  {number}          [now] The database current timestamps
+   * @param {DatabaseOptions} opts Database attribute.
    */
-  constructor(rules, data, auth, now) {
-
-    now = now || Date.now();
+  constructor(opts) {
+    if (opts == null || opts.rules == null) {
+      throw new Error('A database must have rules');
+    }
 
-    this.rules = ruleset.create(rules);
-    this.root = store.create(data, {now: this.timestamp});
-    this.auth = auth || null;
-    this.timestamp = now;
+    this.rules = ruleset.create(opts.rules);
+    this.timestamp = typeof opts.now === 'number' ? opts.now : Date.now();
+    this.root = store.create(opts.data === undefined ? null : opts.data, {now: this.timestamp});
+    this.auth = opts.auth === undefined ? null : opts.auth;
+    this.debug = Boolean(opts.debug);
 
     Object.freeze(this);
-
   }
 
   /**
    * Copy and extend the current database with new value, rules or user.
    *
-   * @param  {{rules: {rules: object}, data: any, auth: any, now: number}} updates Database attribute to replace.
+   * @param  {DatabaseOptions} updates Database attribute to replace.
    * @return {Database}
    */
   with(updates) {
-    const attr = Object.assign({
-      rules: this.rules,
-      data: this.root,
-      auth: this.auth
-    }, updates);
-
-    return new Database(attr.rules, attr.data, attr.auth, attr.now);
+    return new Database({
+      rules: updates.rules == null ? this.rules : updates.rules,
+      data: updates.data == null ? this.root : updates.data,
+      auth: updates.auth == null ? this.auth : updates.auth,
+      now: updates.now,
+      debug: updates.debug == null ? this.debug : updates.debug
+    });
   }
 
   /**
@@ -129,9 +135,7 @@ class Database {
    * at once.
    *
    * @param  {string}      path  Path to the node to write
-   * @param  {Database}    data  Database to edit
    * @param  {object}      patch Map of path/value to update the database.
-   * @param  {object|null} auth  User data to simulate
    * @param  {number}      [now] Timestamp of the write operation
    * @return {{info: string, allowed: boolean}}
    */
@@ -332,6 +336,15 @@ class RuleDataSnapshot {
     return this[pathKey];
   }
 
+  /**
+   * Returns a representation of the snapshot for JSON.stringify.
+   *
+   * @return {{path: string, exists: boolean}}
+   */
+  toJSON() {
+    return {path: this[pathKey], exists: this.exists()};
+  }
+
 }
 
 /**
@@ -343,7 +356,7 @@ class RuleDataSnapshot {
  * @return {Database}
  */
 exports.create = function(rules, data, now) {
-  return new Database(rules, data, null, now);
+  return new Database({rules, data, now});
 };
 
 /**
@@ -357,7 +370,8 @@ exports.create = function(rules, data, now) {
  * @return {RuleDataSnapshot}
  */
 exports.snapshot = function(path, value, now) {
-  const db = new Database({rules: {}}, null);
+  const rules = {rules: {}};
+  const db = new Database({rules});
 
   now = now || Date.now();
 
diff --git a/lib/pad.js b/lib/pad.js
new file mode 100644
index 0000000..d898798
--- /dev/null
+++ b/lib/pad.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const os = require('os');
+
+/**
+ * Pad start of each lines.
+ *
+ *  Options:
+ *
+ * - length: length of the padding (2 by default);
+ * - seq: sequence to pad with (' ' by default);
+ * - eol: platform specific.
+ *
+ * @param  {string}                                     str     String to pad
+ * @param  {{length: number, seq: string, eol: string}} options Padding options
+ * @return {string}
+ */
+exports.lines = function(str, options) {
+  const opts = Object.assign({
+    length: 2,
+    seq: ' ',
+    eol: os.EOL
+  }, options);
+  const padding = opts.seq.repeat(opts.length);
+
+  return str.split(opts.eol)
+    .map(line => `${padding}${line}`)
+    .join(opts.eol);
+};
diff --git a/lib/parser/index.js b/lib/parser/index.js
index 4e63ed4..d496931 100644
--- a/lib/parser/index.js
+++ b/lib/parser/index.js
@@ -20,6 +20,12 @@ require('./statement/logical');
 require('./statement/member');
 require('./statement/unary');
 
+const debugType = new Set([
+  'MemberExpression',
+  'CallExpression',
+  'Identifier'
+]);
+
 class Rule {
 
   constructor(rule, wildchildren, isWrite) {
@@ -43,7 +49,7 @@ class Rule {
   }
 
   toString() {
-    return this.root.toString();
+    return this.root.original;
   }
 
   inferType() {
@@ -54,6 +60,29 @@ class Rule {
     return this.root.evaluate(state);
   }
 
+  debug(state) {
+    const evaluations = new Map([]);
+    const result = this.root.debug(state, ev => {
+      if (debugType.has(ev.type) === false || typeof ev.value === 'function') {
+        return;
+      }
+
+      evaluations.set(`${ev.type}::${ev.original}`, ev);
+    });
+
+    const value = result.value;
+    const detailed = result.detailed;
+    const stack = Array.from(evaluations.values())
+      .sort((a, b) => a.original.localeCompare(b.original))
+      .map(ev => `${ev.original} = ${JSON.stringify(ev.value)}`)
+      .join('\n  ');
+
+    return {
+      value,
+      detailed: stack ? `${detailed}  [=> ${value}]\nusing [\n  ${stack}\n]` : `${detailed}  [=> ${value}]`
+    };
+  }
+
 }
 
 /**
diff --git a/lib/parser/statement/array.js b/lib/parser/statement/array.js
index 6ffd169..00e62e8 100644
--- a/lib/parser/statement/array.js
+++ b/lib/parser/statement/array.js
@@ -25,6 +25,27 @@ class ArrayNode extends Node {
     return this.elements.map(e => e.evaluate(state));
   }
 
+  toString() {
+    const elements = this.elements.map(e => e.toString());
+
+    return `[${elements.join(', ')}]`;
+  }
+
+  debug(state, cb) {
+    const evaluations = this.elements.map(el => el.debug(state, cb));
+    const value = evaluations.map(ev => ev.value);
+    const detailed = `[${evaluations.map(ev => ev.detailed).join(', ')}]`;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
 }
 
 Node.register('ArrayExpression', ArrayNode);
diff --git a/lib/parser/statement/base.js b/lib/parser/statement/base.js
index b8ac650..3ef3939 100644
--- a/lib/parser/statement/base.js
+++ b/lib/parser/statement/base.js
@@ -70,7 +70,7 @@ class Node {
    * @return {string|{name: string, returnType: string, args: function|array}}
    */
   inferType() {
-    throw new ParseError(this, 'not supported');
+    throw new ParseError(this, `inferring ${this.astNode.type} is not supported`);
   }
 
   /**
@@ -80,7 +80,7 @@ class Node {
    * @return {string|number|boolean|RegExp|object}
    */
   evaluate() {
-    throw new ParseError(this, 'not supported');
+    throw new ParseError(this, `evaluating ${this.astNode.type} is not supported`);
   }
 
   /**
@@ -92,6 +92,27 @@ class Node {
     return this.original;
   }
 
+  /**
+   * Yield every evaluation and return formatted representation of the expression.
+   *
+   * @param  {object} state Avalaible variables
+   * @param  {function({original: string, detailed: string, type: string, value: any}): void} cb Evaluation accumulator
+   * @return {string}
+   */
+  debug(state, cb) {
+    const value = this.evaluate(state);
+    const detailed = this.toString();
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
   /**
    * Helper checking a type is one of an allowed list of type.
    *
diff --git a/lib/parser/statement/binary.js b/lib/parser/statement/binary.js
index 50f5bba..c00b18f 100644
--- a/lib/parser/statement/binary.js
+++ b/lib/parser/statement/binary.js
@@ -74,9 +74,35 @@ class BinaryNode extends Node {
     }
   }
 
+  toString() {
+    return `${this.left} ${this.operator} ${this.right}`;
+  }
+
   evaluate(state) {
     const left = this.left.evaluate(state);
     const right = this.right.evaluate(state);
+
+    return this.evaluateWith(state, left, right);
+  }
+
+  debug(state, cb) {
+    const left = this.left.debug(state, cb);
+    const right = this.right.debug(state, cb);
+
+    const value = this.evaluateWith(state, left.value, right.value);
+    const detailed = `${left.detailed} ${this.operator} ${right.detailed}`;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
+  evaluateWith(state, left, right) {
     const lType = types.from(left);
     const rType = types.from(right);
     const mustComply = true;
diff --git a/lib/parser/statement/call.js b/lib/parser/statement/call.js
index 5ea329f..d3d4778 100644
--- a/lib/parser/statement/call.js
+++ b/lib/parser/statement/call.js
@@ -67,6 +67,30 @@ class CallNode extends Node {
     const methodArguments = this.arguments.map(arg => arg.evaluate(state));
     const method = this.callee.evaluate(state);
 
+    return this.evaluateWith(state, methodArguments, method);
+  }
+
+  debug(state, cb) {
+    const argsEvaluation = this.arguments.map(arg => arg.debug(state, cb));
+    const method = this.callee.debug(state, cb);
+
+    const methodArguments = argsEvaluation.map(ev => ev.value);
+    const value = this.evaluateWith(state, methodArguments, method.value);
+
+    const args = argsEvaluation.map(ev => ev.detailed);
+    const detailed = `${method.detailed}(${args.join(', ')})`;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
+  evaluateWith(state, methodArguments, method) {
     if (typeof method !== 'function') {
       throw new ParseError(this, `"${method}" is not a function or method`);
     }
@@ -74,6 +98,12 @@ class CallNode extends Node {
     return method.apply(null, methodArguments);
   }
 
+  toString() {
+    const args = this.arguments.map(a => a.toString());
+
+    return `${this.callee}(${args.join(', ')})`;
+  }
+
 }
 
 Node.register('CallExpression', CallNode);
diff --git a/lib/parser/statement/conditional.js b/lib/parser/statement/conditional.js
index 5a4d92e..2c872d2 100644
--- a/lib/parser/statement/conditional.js
+++ b/lib/parser/statement/conditional.js
@@ -53,7 +53,41 @@ class ConditionalNode extends Node {
   evaluate(state) {
     const test = this.test.evaluate(state);
 
-    return test ? this.consequent.evaluate(state) : this.alternate.evaluate(state);
+    return test === true ? this.consequent.evaluate(state) : this.alternate.evaluate(state);
+  }
+
+  debug(state, cb) {
+    const test = this.test.debug(state, cb);
+    let value, consequent, alternate;
+
+    if (test.value === true) {
+      consequent = this.consequent.debug(state, cb);
+      alternate = {detailed: this.alternate.toString()};
+      value = consequent.value;
+    } else {
+      alternate = this.alternate.debug(state, cb);
+      consequent = {detailed: this.consequent.toString()};
+      value = alternate.value;
+    }
+
+    const detailed = (
+      `${test.detailed}  [=> ${test.value}]  ?\n` +
+      `  ${consequent.detailed}  [=> ${consequent.value}]  :\n` +
+      `  ${alternate.detailed}  [=> ${alternate.value}]\n`
+    );
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
+  toString() {
+    return `${this.test} ?\n  ${this.consequent} :\n  ${this.alternate}`;
   }
 
 }
diff --git a/lib/parser/statement/expression.js b/lib/parser/statement/expression.js
index ce32924..18b6d90 100644
--- a/lib/parser/statement/expression.js
+++ b/lib/parser/statement/expression.js
@@ -24,6 +24,25 @@ class ExpressionNode extends Node {
     return this.expression.evaluate(state);
   }
 
+  toString() {
+    return this.expression.toString();
+  }
+
+  debug(state, cb) {
+    const ev = this.expression.debug(state, cb);
+    const value = ev.value;
+    const detailed = ev.detailed;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
 }
 
 Node.register('ExpressionStatement', ExpressionNode);
diff --git a/lib/parser/statement/literal.js b/lib/parser/statement/literal.js
index 9a52b30..a67a9e9 100644
--- a/lib/parser/statement/literal.js
+++ b/lib/parser/statement/literal.js
@@ -36,6 +36,10 @@ class LiteralNode extends Node {
     return this.value;
   }
 
+  toString() {
+    return JSON.stringify(this.value);
+  }
+
   static regExp(rawValue) {
     const regExpDef = /^\/(.+)\/(.*)$/.exec(rawValue);
 
diff --git a/lib/parser/statement/logical.js b/lib/parser/statement/logical.js
index 3174015..f830c41 100644
--- a/lib/parser/statement/logical.js
+++ b/lib/parser/statement/logical.js
@@ -8,6 +8,7 @@
 'use strict';
 
 const base = require('./base');
+const pad = require('../../pad');
 
 const Node = base.Node;
 const ParseError = base.ParseError;
@@ -60,6 +61,61 @@ class LogicalNode extends Node {
     }
   }
 
+  debug(state, cb) {
+    let value, left, right;
+
+    switch (this.operator) {
+
+    case '&&':
+      left = this.left.debug(state, cb);
+      value = left.value;
+      if (!left.value) {
+        right = {detailed: this.right.toString()};
+        break;
+      }
+
+      right = this.right.debug(state, cb);
+      value = right.value;
+      break;
+
+    case '||':
+      left = this.left.debug(state, cb);
+      value = left.value;
+      if (left.value) {
+        right = {detailed: this.right.toString()};
+        break;
+      }
+
+      right = this.right.debug(state, cb);
+      value = right.value;
+      break;
+
+    default:
+      throw new ParseError(this, `unknown logical operator ${this.operator}`);
+
+    }
+
+    const lFormat = pad.lines(left.detailed).trimRight();
+    const rFormat = pad.lines(right.detailed).trim();
+    const detailed = `(\n${lFormat}  [=> ${left.value}]\n  ${this.operator} ${rFormat}  [=> ${right.value}]\n)`;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {value, detailed};
+  }
+
+  toString() {
+    const left = pad.lines(this.left.toString()).trimRight();
+    const right = pad.lines(this.right.toString()).trimRight();
+
+    return `(\n${left} ${this.operator}\n${right}\n)`;
+  }
+
 }
 
 Node.register('LogicalExpression', LogicalNode);
diff --git a/lib/parser/statement/member.js b/lib/parser/statement/member.js
index 1ddd7e0..fe9ad88 100644
--- a/lib/parser/statement/member.js
+++ b/lib/parser/statement/member.js
@@ -129,6 +129,37 @@ class MemberNode extends Node {
   evaluate(state) {
     const object = this.object.evaluate(state);
     const key = this.computed ? this.property.evaluate(state) : this.property.name;
+
+    return this.evaluateWith(state, object, key);
+  }
+
+  debug(state, cb) {
+    const object = this.object.debug(state, cb);
+    let detailed, key;
+
+    if (this.computed) {
+      const ev = this.property.debug(state, cb);
+
+      key = ev.value;
+      detailed = `${object.detailed}[${ev.detailed}]`;
+    } else {
+      key = this.property.name;
+      detailed = `${object.detailed}.${key}`;
+    }
+
+    const value = this.evaluateWith(state, object.value, key);
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
+  evaluateWith(state, object, key) {
     const isPatched = types.isString(types.from(object)) && stringMethods[key];
     const property = isPatched ? stringMethods[key] : (object && object[key]);
 
@@ -143,6 +174,10 @@ class MemberNode extends Node {
     return isPatched ? property.bind(null, object) : property.bind(object);
   }
 
+  toString() {
+    return this.computer ? `${this.object}[${this.property}]` : `${this.object}.${this.property}`;
+  }
+
 }
 
 Node.register('MemberExpression', MemberNode);
diff --git a/lib/parser/statement/unary.js b/lib/parser/statement/unary.js
index 741b093..9ffaed6 100644
--- a/lib/parser/statement/unary.js
+++ b/lib/parser/statement/unary.js
@@ -61,6 +61,26 @@ class UnaryNode extends Node {
   evaluate(state) {
     const value = this.argument.evaluate(state);
 
+    return this.evaluateWith(state, value);
+  }
+
+  debug(state, cb) {
+    const argument = this.argument.debug(state, cb);
+    const value = this.evaluateWith(state, argument.value);
+    const detailed = `${this.operator}(${argument.detailed})`;
+
+    cb({
+      type: this.astNode.type,
+      original: this.original,
+      detailed,
+      value
+    });
+
+    return {detailed, value};
+  }
+
+  evaluateWith(state, value) {
+
     switch (this.operator) {
 
     case '!':
@@ -76,6 +96,10 @@ class UnaryNode extends Node {
     }
   }
 
+  toString() {
+    return `${this.operator}(${this.argument})`;
+  }
+
 }
 
 Node.register('UnaryExpression', UnaryNode);
diff --git a/lib/results.js b/lib/results.js
index 794f6b7..80a97e6 100644
--- a/lib/results.js
+++ b/lib/results.js
@@ -4,6 +4,8 @@
 
 'use strict';
 
+const pad = require('./pad');
+
 /**
  * Hold an evaluation result.
  */
@@ -22,8 +24,7 @@ class Result {
     this.auth = isRead ? db.auth : opts.newDatabase.auth;
     this.type = operationType;
     this.logs = [];
-    this.readPermitted = !isRead;
-    this.writePermitted = isRead;
+    this.permitted = false;
     this.validated = true;
     this.database = db;
     this.newDatabase = opts.newDatabase;
@@ -31,31 +32,38 @@ class Result {
   }
 
   get allowed() {
-    return this.readPermitted && this.writePermitted && this.validated;
+    return this.permitted && this.validated;
   }
 
   get info() {
-    let logs = this.logs
-      .map(r => `/${r.path}: ${r.kind} "${r.rule}"\n    => ${r.result}`)
-      .join('\n');
+    const logs = this.logs.map(r => {
+      const header = `/${r.path}: ${r.kind} "${r.rule}"`;
+      const result = r.error == null ? r.value : r.error;
+
+      if (r.detailed == null) {
+        return `${header}\n    => ${result}`;
+      }
+
+      return `${header}  => ${result}\n${pad.lines(r.detailed)}\n`;
+    });
 
     if (this.allowed) {
-      return `${logs}\n${this.type} was allowed.`;
-    }
+      logs.push(`${this.type} was allowed.`);
 
-    if (!this.writePermitted) {
-      logs += '\nNo .write rule allowed the operation.';
+      return logs.join('\n');
     }
 
-    if (!this.readPermitted) {
-      logs += '\nNo .read rule allowed the operation.';
+    if (!this.permitted) {
+      logs.push(`No .${this.type} rule allowed the operation.`);
     }
 
     if (!this.validated) {
-      logs += '\nNo .validate rule allowed the operation.';
+      logs.push('One or more .validate rules disallowed the operation.');
     }
 
-    return `${logs}\n${this.type} was denied.`;
+    logs.push(`${this.type} was denied.`);
+
+    return logs.join('\n');
   }
 
   get root() {
@@ -77,28 +85,31 @@ class Result {
   /**
    * Logs the evaluation result.
    *
-   * @param {string}        path   The rule path
-   * @param {string}        kind   The rule kind
-   * @param {NodeRule}      rule   The rule
-   * @param {boolean|Error} result The evaluation result
+   * @param {string}   path   The rule path
+   * @param {string}   kind   The rule kind
+   * @param {NodeRule} rule   The rule
+   * @param {{value: boolean, error: Error, detailed: string}}  result The evaluation result
    */
   add(path, kind, rule, result) {
-    this.logs.push({path, kind, result, rule: rule.toString()});
 
-    const success = result instanceof Error ? false : result;
+    this.logs.push({
+      path,
+      kind,
+      rule: rule.toString(),
+      value: result.value == null ? false : result.value,
+      error: result.error,
+      detailed: result.detailed
+    });
 
     switch (kind) {
 
     case 'validate':
-      this.validated = this.validated && success;
+      this.validated = this.validated && result.value === true;
       break;
 
     case 'write':
-      this.writePermitted = this.writePermitted || success;
-      break;
-
     case 'read':
-      this.readPermitted = this.readPermitted || success;
+      this.permitted = this.permitted || result.value === true;
       break;
 
     /* istanbul ignore next */
@@ -148,12 +159,12 @@ exports.write = function(path, data, newDatabase, newValue) {
 exports.update = function(path, data, patch, writeResults) {
   const result = new Result(path, 'patch', data, {newDatabase: data, newValue: patch});
 
-  result.writePermitted = true;
+  result.permitted = true;
 
   writeResults.forEach(r => {
     result.newDatabase = r.newDatabase;
     result.logs = result.logs.concat(r.logs);
-    result.writePermitted = r.writePermitted && result.writePermitted;
+    result.permitted = r.permitted && result.permitted;
     result.validated = r.validated && result.validated;
   });
 
diff --git a/lib/ruleset.js b/lib/ruleset.js
index 5d325fb..cd5f87f 100644
--- a/lib/ruleset.js
+++ b/lib/ruleset.js
@@ -134,7 +134,14 @@ class Ruleset {
       }
 
       state = Object.assign({}, state, wildchildren, {data: data.snapshot(currentPath)});
-      Ruleset.evaluate(rules, 'read', currentPath, state, result);
+      Ruleset.evaluate({
+        rules,
+        kind: 'read',
+        path: currentPath,
+        state,
+        result,
+        debug: data.debug
+      });
 
       return result.allowed;
 
@@ -155,6 +162,7 @@ class Ruleset {
    */
   tryWrite(path, data, newData, newValue) {
     const stop = true;
+    const debug = newData.debug;
     const result = results.write(path, data, newData, newValue);
     let state = {
       root: data.snapshot('/'),
@@ -169,16 +177,30 @@ class Ruleset {
         newData: newData.snapshot(currentPath)
       });
 
-      if (result.writePermitted && !state.newData.exists()) {
+      if (result.permitted && !state.newData.exists()) {
         return stop;
       }
 
-      if (!result.writePermitted) {
-        Ruleset.evaluate(rules, 'write', currentPath, state, result);
+      if (!result.permitted) {
+        Ruleset.evaluate({
+          rules,
+          kind: 'write',
+          path: currentPath,
+          state,
+          result,
+          debug
+        });
       }
 
       if (state.newData.exists()) {
-        Ruleset.evaluate(rules, 'validate', currentPath, state, result);
+        Ruleset.evaluate({
+          rules,
+          kind: 'validate',
+          path: currentPath,
+          state,
+          result,
+          debug
+        });
       }
 
       return !stop;
@@ -203,7 +225,14 @@ class Ruleset {
         newData: snap
       });
 
-      Ruleset.evaluate(child.rules, 'validate', childPath, state, result);
+      Ruleset.evaluate({
+        rules: child.rules,
+        kind: 'validate',
+        path: childPath,
+        state,
+        result,
+        debug
+      });
 
       return !stop;
     });
@@ -214,26 +243,22 @@ class Ruleset {
   /**
    * Helper function evaluating a rule and logging the result.
    *
-   * @param  {Rule}   rules  Rule to evaluate
-   * @param  {string} kind   Rule Kind
-   * @param  {string} path   Data path
-   * @param  {object} state  State to evaluate the rule with
-   * @param  {Result} result Result object to log the result to
+   * @param {{rules: Rule, kind: string, path: string, state: object, result: Result, debug: boolean}} options Options
    * @private
    */
-  static evaluate(rules, kind, path, state, result) {
-    const rule = rules[`$${kind}`];
+  static evaluate(options) {
+    const rule = options.rules[`$${options.kind}`];
 
     if (!rule) {
       return;
     }
 
     try {
-      const allowed = rule.evaluate(state);
+      const result = options.debug ? rule.debug(options.state) : {value: rule.evaluate(options.state)};
 
-      result.add(path, kind, rule, allowed);
-    } catch (e) {
-      result.add(path, kind, rule, e);
+      options.result.add(options.path, options.kind, rule, result);
+    } catch (error) {
+      options.result.add(options.path, options.kind, rule, {error});
     }
   }
 
diff --git a/lib/util.js b/lib/util.js
index c42ff5b..a029686 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -7,8 +7,10 @@
 const ruleset = require('./ruleset');
 const store = require('./store');
 const database = require('./database');
+const pad = require('./pad');
 
 let debug = true;
+let verbose = true;
 let data, rules, db;
 
 const users = exports.users = {
@@ -76,66 +78,69 @@ function getUserDescription(auth) {
 
 }
 
-exports.readableError = function(result) {
+function debugInfo(msg, result, padding) {
+  const info = debug === true ? `${msg}\n${result.info}` : msg;
 
-  let msg = 'Expected ' + getUserDescription(result.auth) +
+  return padding == null ? info : pad.lines(info, {length: padding});
+}
+
+exports.readableError = function(result, padding) {
+
+  const msg = 'Expected ' + getUserDescription(result.auth) +
     ' not to be able to read ' + result.path +
     ', but the rules allowed the read.';
 
-  if (debug) {
-    msg += '\n' + result.info;
-  }
-
-  return msg;
+  return debugInfo(msg, result, padding);
 
 };
 
-exports.unreadableError = function(result) {
+exports.unreadableError = function(result, padding) {
 
-  let msg = 'Expected ' + getUserDescription(result.auth) +
+  const msg = 'Expected ' + getUserDescription(result.auth) +
     ' to be able to read ' + result.path +
     ', but the rules denied the read.';
 
-  if (debug) {
-    msg += '\n' + result.info;
-  }
-
-  return msg;
+  return debugInfo(msg, result, padding);
 
 };
 
-exports.writableError = function(result) {
+exports.writableError = function(result, padding) {
 
-  let msg = 'Expected ' + getUserDescription(result.auth) +
+  const msg = 'Expected ' + getUserDescription(result.auth) +
     ' not to be able to write ' + JSON.stringify(result.newValue) +
     ' to ' + result.path +
     ', but the rules allowed the write.';
 
-  if (debug) {
-    msg += '\n' + result.info;
-  }
-
-  return msg;
+  return debugInfo(msg, result, padding);
 
 };
 
-exports.unwritableError = function(result) {
+exports.unwritableError = function(result, padding) {
 
-  let msg = 'Expected ' + getUserDescription(result.auth) +
+  const msg = 'Expected ' + getUserDescription(result.auth) +
     ' to be able to write ' + JSON.stringify(result.newValue) +
     ' to ' + result.path +
     ', but the rules denied the write.';
 
-  if (debug) {
-    msg += '\n' + result.info;
-  }
-
-  return msg;
+  return debugInfo(msg, result, padding);
 
 };
 
 exports.setDebug = function(newDebug) {
   debug = newDebug === true;
+  exports.setVerbose(false);
+};
+
+exports.setVerbose = function(newVerbose) {
+  verbose = newVerbose === true;
+
+  if (verbose === true) {
+    debug = true;
+  }
+
+  if (db) {
+    db = db.with({debug: verbose});
+  }
 };
 
 exports.assertConfigured = function() {
@@ -167,7 +172,7 @@ exports.getFirebaseData = function() {
   exports.assertConfigured();
 
   if (!db) {
-    db = database.create(rules, data.value, data.now);
+    db = database.create(rules, data.value, data.now).with({debug: verbose});
   }
 
   return db;
diff --git a/plugins/chai.js b/plugins/chai.js
index b240424..124ac16 100644
--- a/plugins/chai.js
+++ b/plugins/chai.js
@@ -73,9 +73,9 @@ function chaiTargaryen(chai, utils) {
       result = data.read(path, now);
 
       if (positivity) {
-        chai.assert(result.allowed === true, targaryen.util.unreadableError(result));
+        chai.assert(result.allowed === true, targaryen.util.unreadableError(result, 6).trim());
       } else {
-        chai.assert(result.allowed === false, targaryen.util.readableError(result));
+        chai.assert(result.allowed === false, targaryen.util.readableError(result, 6).trim());
       }
 
       return;
@@ -95,9 +95,9 @@ function chaiTargaryen(chai, utils) {
     }
 
     if (positivity) {
-      chai.assert(result.allowed === true, targaryen.util.unwritableError(result));
+      chai.assert(result.allowed === true, targaryen.util.unwritableError(result, 6).trim());
     } else {
-      chai.assert(result.allowed === false, targaryen.util.writableError(result));
+      chai.assert(result.allowed === false, targaryen.util.writableError(result, 6).trim());
     }
 
   });
@@ -106,6 +106,7 @@ function chaiTargaryen(chai, utils) {
 
 chaiTargaryen.users = targaryen.util.users;
 chaiTargaryen.setDebug = targaryen.util.setDebug;
+chaiTargaryen.setVerbose = targaryen.util.setVerbose;
 chaiTargaryen.setFirebaseData = targaryen.util.setFirebaseData;
 chaiTargaryen.setFirebaseRules = targaryen.util.setFirebaseRules;
 
diff --git a/plugins/jasmine.js b/plugins/jasmine.js
index e71dfc2..0923e72 100644
--- a/plugins/jasmine.js
+++ b/plugins/jasmine.js
@@ -11,6 +11,7 @@ const targaryen = require('../');
 exports.setFirebaseData = targaryen.util.setFirebaseData;
 exports.setFirebaseRules = targaryen.util.setFirebaseRules;
 exports.setDebug = targaryen.util.setDebug;
+exports.setVerbose = targaryen.util.setVerbose;
 exports.users = targaryen.util.users;
 
 exports.matchers = {


Test patch:
diff --git a/test/spec/lib/parser/rule.js b/test/spec/lib/parser/rule.js
index 6359dae..19ea488 100644
--- a/test/spec/lib/parser/rule.js
+++ b/test/spec/lib/parser/rule.js
@@ -86,4 +86,142 @@ describe('Rule', function() {
 
   });
 
+  describe('#debug', function() {
+
+    it('should format literal', function() {
+      const rule = parser.parse('true', []);
+      const state = {};
+
+      expect(rule.debug(state).detailed).to.equal('true  [=> true]');
+    });
+
+    it('should format binary', function() {
+      const rule = parser.parse('2 > 1', []);
+      const state = {};
+
+      expect(rule.debug(state).detailed).to.equal('2 > 1  [=> true]');
+    });
+
+    it('should format string call', function() {
+      const rule = parser.parse('"foo".contains("o")', []);
+      const state = {};
+
+      expect(rule.debug(state).detailed).to.equal(
+        '"foo".contains("o")  [=> true]\n' +
+        'using [\n' +
+        '  "foo".contains("o") = true\n' +
+        ']'
+      );
+    });
+
+    it('should format snapshot call', function() {
+      const rule = parser.parse('root.hasChildren()', []);
+      const state = {root: database.snapshot('/', null)};
+
+      expect(rule.debug(state).detailed).to.equal(
+        'root.hasChildren()  [=> false]\n' +
+        'using [\n' +
+        '  root = {"path":"","exists":false}\n' +
+        '  root.hasChildren() = false\n' +
+        ']'
+      );
+    });
+
+    it('should format ternary expression 1/2', function() {
+      const rule = parser.parse('auth.isAdmin === true ? true : root.child("open").exists()', []);
+      const state = {root: database.snapshot('/', null), auth: {isAdmin: true}};
+
+      expect(rule.debug(state).detailed).to.equal(
+        'auth.isAdmin === true  [=> true]  ?\n' +
+        '  true  [=> true]  :\n' +
+        '  root.child("open").exists()  [=> undefined]\n' +
+        '  [=> true]\n' +
+        'using [\n' +
+        '  auth = {"isAdmin":true}\n' +
+        '  auth.isAdmin = true\n' +
+        ']'
+      );
+    });
+
+    it('should format ternary expression 2/2', function() {
+      const rule = parser.parse('auth.isAdmin === false ? root.child("open").exists() : true', []);
+      const state = {root: database.snapshot('/', null), auth: {isAdmin: true}};
+
+      expect(rule.debug(state).detailed).to.equal(
+        'auth.isAdmin === false  [=> false]  ?\n' +
+        '  root.child("open").exists()  [=> undefined]  :\n' +
+        '  true  [=> true]\n' +
+        '  [=> true]\n' +
+        'using [\n' +
+        '  auth = {"isAdmin":true}\n' +
+        '  auth.isAdmin = true\n' +
+        ']'
+      );
+    });
+
+    it('should format indentifier', function() {
+      const rule = parser.parse('$foo == "bar"', ['$foo']);
+      const state = {$foo: 'bar'};
+
+      expect(rule.debug(state).detailed).to.equal(
+        '$foo == "bar"  [=> true]\n' +
+        'using [\n' +
+        '  $foo = "bar"\n' +
+        ']'
+      );
+    });
+
+    it('should format logical expression', function() {
+      const rule = parser.parse(
+        'root.hasChild("foo") || root.hasChild("bar") || root.hasChild("baz")',
+        []
+      );
+      const state = {root: database.snapshot('/', {bar: true})};
+
+      expect(rule.debug(state).detailed).to.equal(
+        '(\n' +
+        '  (\n' +
+        '    root.hasChild("foo")  [=> false]\n' +
+        '    || root.hasChild("bar")  [=> true]\n' +
+        '  )  [=> true]\n' +
+        '  || root.hasChild("baz")  [=> undefined]\n' +
+        ')  [=> true]\n' +
+        'using [\n' +
+        '  root = {"path":"","exists":true}\n' +
+        '  root.hasChild("bar") = true\n' +
+        '  root.hasChild("foo") = false\n' +
+        ']'
+      );
+    });
+
+    it('should format unary expressions', function() {
+      const rule = parser.parse('!(root.exists())', []);
+      const state = {root: database.snapshot('/', null)};
+
+      expect(rule.debug(state).detailed).to.equal(
+        '!(root.exists())  [=> true]\n' +
+        'using [\n' +
+        '  root = {"path":"","exists":false}\n' +
+        '  root.exists() = false\n' +
+        ']'
+      );
+    });
+
+    it('should format array expressions', function() {
+      const rule = parser.parse('root.hasChildren(["foo",auth.bar])', []);
+      const state = {root: database.snapshot('/', null), auth: {bar: 'baz'}};
+
+      expect(rule.debug(state).detailed).to.equal(
+        'root.hasChildren(["foo", auth.bar])  [=> false]\n' +
+        'using [\n' +
+        '  auth = {"bar":"baz"}\n' +
+        '  auth.bar = "baz"\n' +
+        '  root = {"path":"","exists":false}\n' +
+        '  root.hasChildren(["foo",auth.bar]) = false\n' +
+        ']'
+      );
+    });
+
+  });
+
 });