src/lens.js

import isFunction from './functional/isFunction.js';
import isUndefined from './functional/isUndefined.js';
import { cloneImpl, isLensClass } from '../src-cjs/constants.js';
import CustomStep from './custom_step.js';
import fusion from './fusion.js';
import { smartLog } from './logger.js';
import Optic from './optic.js';
import OpticArray from './optic_array.js';
import { getIterator, handleNoniterableValue, index_maybe, isLens } from './utils.js';

// Polyfill support for lenses to standard JavaScript types
import './stdlib_support/object.js';
import './stdlib_support/array.js';
import './stdlib_support/map.js';

/**
 * @typedef {Object} OptionalThrow
 * @property {*} [orThrow]  The value to `throw` in case of an error.
 */

/**
 * @typedef {Object} FallbackBindingResult
 * @property {Function} [or]  The function to return if the slot does not contain a function.
 */

/**
 * @typedef {Object} Maybe
 * @property {*} [just]  The contained value
 * @see Haskell's "Maybe" data type
 *
 * @description
 * The presence of `just` as a property indicates the "Just" construction of
 * the Maybe monad — the presence of a value (even if `undefined`).  A Maybe
 * without a `just` property is the "Nothing" construction.
 */

/**
 * @extends Optic
 * @property {Array.<*>} keys  Indexing/subscripting values to be applied successively to subjects of this lens
 */
class Lens extends Optic {
  /**
   * @summary Class for operating immutably on a specific "slot" in complex data
   * @extends Optic
   * @param {...*} key  A value to use in an application of subscripting (i.e. square bracket operator)
   *
   * @description
   * A Lens constructed as `let l = new Lens('address', 'street', 0)` represents
   * a certain slot within a complex subject value.  If we had a record
   * ```js
   * const record = {
   *   address: {
   *     street: ["123 Somewhere Ln.", "Apt. 42"]
   *   }
   * };
   * ```
   * then applying our lens with `l.get(record)` would evaluate to "123 Somewhere Ln.".
   *
   * **NOTE:** If a *key* of a Lens is a negative number and
   * the container accessed at that level is an Array, the negtive index works
   * like `Array.prototype.slice`, counting from the end of the Array.
   *
   * But Lenses offer more functionality than retrieval of values from a deeply
   * structured value — they can create a minimally cloned value deeply equal
   * to the subject but for the slot targeted by the lens and strictly equal
   * but for the slot targeted by the lens and the nodes in the input subject
   * traversed to reach that slot in the subject's tree.
   *
   * When constructing a modified clone, it is possible that some step of the
   * Lens will target a slot within a container-not-existing-in-subject.  In this
   * case, the container to be created is intuited from the key that would
   * access it: an Array if the key is a number, otherwise an Object.  Alternative
   * container construction can be specified via {@link Factory} or {@link Step}.
   *
   * Typically, instances are constructed by calling the Function exported
   * from `natural-lenses` (if `require`d) or its default export (if `import`ed),
   * conventionally named `lens`.
   */
  constructor(...keys) {
    super();
    this.keys = keys;
  }
  
  /**
   * @inheritdoc
   */
  thence(...keys) {
    return new Lens(...this.keys, ...keys);
  }

  /**
   * @summary Get a combination of presence and value of this slot
   * @param {*} subject  The data to query
   * @param {...*} tail  Additional subject for repeated application
   * @return {Maybe.<*>}  Empty Object if this slot is not present in *subject*,
   *                      otherwise Object with `just` property containing value of this slot in *subject*
   *
   * @description
   * This implements the Maybe monad (familiar from Haskell), where Nothing is
   * represented as an empty Object and Just is represented as an Object with a
   * `just` property containing the value in the slot.
   *
   * If *tail* is given and getting this slot from from *subject* yields a
   * Lens (as indicated by a truthy `lens.isLens` property), then `#get_maybe()`
   * is called on that Lens, passing the spread of *tail*.  If the value of
   * this slot is *not* a Lens, the result is an empty Object.
   */
  get_maybe(subject, ...tail) {
    let cur = subject;
    for (let i = 0; i < this.keys.length; i++) {
      const k = this.keys[i];
      const next_maybe = (function() {
        if (k instanceof CustomStep) {
          return k.get_maybe(cur);
        } else {
          return index_maybe(cur, k);
        }
      }());
      if ('just' in next_maybe) {
        cur = next_maybe.just;
      } else {
        return {}
      }
    }
    if (tail.length > 0) {
      return isLens(cur) ? cur.get_maybe(...tail) : {};
    }
    return {just: cur};
  }

  /**
   * @template T
   * @summary Clone *subject*, setting the value of this slot within the clone
   * @param {T} subject  The input structured data
   * @param {*} newVal   The new value to inject into the slot identified by this lens
   * @return {T} A minimally changed clone of *subject* with *newVal* in this slot
   *
   * @description
   * "Minimally changed" means that reference-copies are used wherever possible
   * while leaving subject unchanged, and that setting the slot to the strict-
   * equal value it already has results in returning subject.
   */
  setInClone(subject, newVal) {
    const slots = new Array(this.keys.length);
    let cur = subject;
    for (let i = 0; i < this.keys.length; i++) {
      const k = this.keys[i];
      const slot = slots[i] = makeSlot(cur, k);
      const next_maybe = slot.get_maybe();
      if ('just' in next_maybe) {
        cur = next_maybe.just;
      } else if (i + 1 < this.keys.length) {
        cur = this._constructFor(i + 1);
      }
    }
    if (slots[slots.length - 1].get() === newVal) {
      return subject;
    }
    cur = newVal;
    for (let i = slots.length - 1; i >= 0; i--) {
      cur = slots[i].cloneAndSet(cur);
    }
    return cur;
  }

  /**
   * @template T
   * @summary Clone the input, transforming the value within this slot with a function
   * @param {T}              subject  The input structured data
   * @param {function(*): *} fn  The function that transforms the slot value
   * @param {Object}         [opts]
   * @param {Boolean}        opts.addMissinng=false  Whether to add the slot if missing in subject
   * @return {T} A minimally changed clone of *subject* with *fn* applied to the value in this slot
   *
   * @description
   * If this slot is missing in subject, fn will not be called unless *addMissing*
   * is true, in which case fn will be called with undefined.
   *
   * "Minimally changed" means that reference-copies are used wherever possible
   * while leaving subject unchanged, and that setting the slot to the strict-
   * equal value it already has results in returning subject.
   */
  xformInClone(subject, fn, {addMissing = false} = {}) {
    const slots = new Array(this.keys.length);
    let cur = subject;
    for (let i = 0; i < this.keys.length; i++) {
      const k = this.keys[i];
      const slot = slots[i] = makeSlot(cur, k);
      const next_maybe = slot.get_maybe();
      if ('just' in next_maybe) {
        cur = next_maybe.just;
      } else if (addMissing) {
        if (i + 1 < this.keys.length) {
          cur = this._constructFor(i + 1);
        }
      } else {
        return subject;
      }
    }
    if (slots.length) {
      const prevVal = slots[slots.length - 1].get();
      cur = fn(prevVal);
      if (cur === prevVal) {
        return subject;
      }
      for (let i = slots.length - 1; i >= 0; i--) {
        cur = slots[i].cloneAndSet(cur);
      }
    } else {
      cur = fn(subject);
    }
    return cur;
  }

  /**
   * @template T
   * @summary Clone the input, transforming or deleting the Maybe value of this slot with a function
   * @param {T}                      subject  The input structured data
   * @param {function(Maybe): Maybe} fn       The function transforming the {@link Maybe} value of the slot
   * @return {T} A minimally changed clone of *subject* with this slot transformed per *fn*
   *
   * @description
   * The value given to *fn* will be the result of {@link Lens#get_maybe}, which
   * indicates absence of this slot in *subject* by omitting the `just` property,
   * which otherwise contains the value of this slot in *subject*.  The value
   * returned by *fn* is the result that should be returned by calling
   * {@link Lens#get_maybe} of this slot on the modified clone: if no `just`
   * property is returned by *fn*, the slot is deleted from the clone, and
   * the value of the `just` property is otherwise set for this slot in the
   * clone.
   *
   * This implements what would, in Haskell, be described as:
   *
   *    Json -> (Maybe Json -> Maybe Json) -> Json
   *
   * "Minimally changed" means that reference-copies are used wherever possible
   * while leaving subject unchanged, and that setting the slot to the strict-
   * equal value it already has results in returning subject.
   */
  xformInClone_maybe(subject, fn) {
    const slots = new Array(this.keys.length);
    let cur = subject, present = true;
    for (let i = 0; i < this.keys.length; i++) {
      const k = this.keys[i];
      const slot = slots[i] = makeSlot(cur, k);
      const next_maybe = slot.get_maybe();
      if ('just' in next_maybe) {
        cur = next_maybe.just;
      } else {
        present = false;
        if (i + 1 < this.keys.length) {
          cur = this._constructFor(i + 1);
        }
      }
    }
    if (slots.length) {
      const prevVal = slots[slots.length - 1].get();
      const maybe_val = fn(present ? {just: prevVal} : {});
      const setting = 'just' in maybe_val;
      if (present && !setting) {
        cur = slots[slots.length - 1].cloneOmitting();
        if (cur === slots[slots.length - 1].subject) {
          return subject;
        }
        for (let i = slots.length - 2; i >= 0; i--) {
          cur = slots[i].cloneAndSet(cur);
        }
      } else if (setting) {
        if (present && prevVal === maybe_val.just) {
          return subject;
        }
        cur = maybe_val.just;
        for (let i = slots.length - 1; i >= 0; i--) {
          cur = slots[i].cloneAndSet(cur);
        }
      } else {
        return subject;
      }
    } else {
      cur = fn({just: subject}).just;
    }
    return cur;
  }
  
  /**
   * @summary DRYly bind a Function to the Object from which it was obtained
   * @param {*} subject  The input structured data
   * @param {Object} [options]
   * @param {boolean} [options.bindNow=true]  Bind to the target of this lens with *subject* now rather than when the result function is invoked
   * @param {*} [options.orThrow]  {@link OptionalThrow} if the slot referenced does not contain a Function; has precedence over *or*
   * @param {Function} [options.or]  {@link FallbackBindingResult}, a Function to return if the slot referenced does not contain a Function
   * @return {Function} A Function bound to the previous object in the chain used to access the Function, or `function() {}` if no such function found
   *
   * The return value of this method is *always* a Function; if the slot identified
   * by this Lens is not present in *subject* or does not contain a Function,
   * the trivial function (`function () {}` or equivalent) will be returned.
   *
   * @description
   * Use this to avoid the dreaded Javascript binding repetition of
   * `x.y.z.fn.bind(x.y.z)`.  Instead, use `lens('y', 'z', 'fn').bound(x)`.
   */
  bound(subject, {bindNow = true, orThrow, or} = {}) {
    const lens = this;
    function lookUpPlayers() {
      const lCopy = new Lens(...lens.keys), mname = lCopy.keys.pop();
      const mSubj = lCopy.get(subject), fn = (function() {
        try {return mSubj[mname];} catch (e) {}
      }());
      return {mSubj, fn};
    }
    if (bindNow) {
      const {mSubj, fn} = lookUpPlayers();
      if (isFunction(fn)) {
        return fn.bind(mSubj);
      }
      if (orThrow) {
        throw orThrow;
      } else if (!isUndefined(or)) {
        return or;
      }
      return function() {};
    } else return function (...args) {
      const {mSubj, fn} = lookUpPlayers();
      if (isFunction(fn)) {
        return fn.apply(mSubj, args);
      }
      if (orThrow) {
        throw orThrow;
      } else if (!isUndefined(or)) {
        return or.apply(undefined, args);
      }
    };
  }

  /**
   * Combine the effects of multiple Lenses
   *
   * It is preferred to use {@link module:natural-lenses#fuse}
   */
  static fuse(...lenses) {
    if (!lenses.every(l => l.constructor === Lens)) {
      smartLog({
        level: 'error',
        trace: true,
        message: "Expected all arguments to be exactly Lens (no derived classes)",
        msgId: '105c91ddea3a',
        argClasses: lenses.map(l => l.constructor),
      });
      throw new Error("Expected all arguments to be exactly Lens (no derived classes)");
    }
    return new Lens(...lenses.flatMap(l => l.keys));
  }
  
  /*
   * @package
   * @summary Construct a container for a clone given the depth
   */
  _constructFor(depth) {
    const key = this.keys[depth];
    if (key instanceof CustomStep) {
      return key.construct();
    }
    return (typeof key === 'number') ? [] : {};
  }
}

class Slot {
  constructor(target, key) {
    this.target = target;
    this.key = key;
  }

  get() {
    return this.get_maybe().just;
  }

  get_maybe() {
    return index_maybe(this.target, this.key);
  }

  cloneAndSet(val) {
    const rval = this.cloneTarget({set: [this.key, val]});
    return rval;
  }

  cloneOmitting() {
    const rval = this.cloneTarget({spliceOut: this.key});
    return rval;
  }

  cloneTarget(opDesc /* {set, spliceOut} */ = {}) {
    let target = this.target;
    if (!target) {
      target = (typeof key === 'number') ? [] : {};
    }
    return target[cloneImpl](opDesc);
  }
}

class CSSlot {
  constructor(target, customStep) {
    this.target = target;
    this.customStep = customStep;
  }
  
  get() {
    return this.get_maybe().just;
  }
  
  get_maybe() {
    return this.customStep.get_maybe(this.target);
  }
  
  cloneAndSet(val) {
    return this.customStep.updatedClone(this.target, {just: val});
  }
  
  cloneOmitting() {
    return this.customStep.updatedClone(this.target, {});
  }
}

// Monkey-patch Optic here with `thence` to avoid cyclic dependency in optic.js
let fuse = null;
/**
 * @function Optic#thence
 * @summary Build a "deeper" lens/optic
 * @param {...*} key  A value to use in an application of subscripting (i.e. square bracket operator)
 * @returns {Lens|OpticArray}  An Optic that fuses this optic with a Lens looking at finer detail
 *
 * @description
 * This method creates a new Optic that looks at finer detail than the Optic
 * on which it was called.  It either actually or effectively fuses a new
 * {@link Lens} to the right of this optic, as with [fuse]{@link module:natural-lenses#fuse}.
 */
Optic.prototype.thence = function(...keys) {
  fuse = fuse || fusion({ Lens, OpticArray});
  return fuse(this, new Lens(...keys));
}

function makeSlot(cur, k) {
  return new ((k instanceof CustomStep) ? CSSlot : Slot)(cur, k);
}

export default Lens;