Source: CrioArray.js

// external dependencies
import stringify from 'json-stringify-safe';
import hashIt from 'hash-it';
import {parse} from 'pathington';
import {get, has, merge, remove, set} from 'unchanged';

// classes
import CrioObject from './CrioObject';

// constants
import {ARRAY_FALLBACK_PROTOTYPE_METHODS, ARRAY_UNSCOPABLES} from './constants';

// is
import {isArray, isCrio, isEqual, isUndefined} from './is';

// utils
import {
  createIterator,
  find,
  getCrioedObject,
  getEntries,
  getRelativeValue,
  getValues,
  thaw
} from './utils';

let hasAppliedPrototype = false;

class CrioArray extends Array {
  constructor(array) {
    super(isArray(array) ? array.length : array || 0);

    if (!hasAppliedPrototype) {
      applyPrototype();

      hasAppliedPrototype = true;
    }

    if (isCrio(array)) {
      return array.toArray();
    }

    return isArray(array)
      ? array.reduce((crioArray, item, index) => {
          crioArray[index] = getCrioedObject(item);

          return crioArray;
        }, this)
      : this;
  }

  get hashCode() {
    return hashIt(this, true);
  }

  /**
   * @function clear
   * @memberof CrioArray
   *folder
   * @description
   * get a new empty array
   *
   * @returns {CrioArray} the empty array
   */
  clear() {
    return new CrioArray();
  }

  /**
   * @function compact
   * @memberof CrioArray
   *
   * @description
   * get a new array with values from the original array that are truthy
   *
   * @returns {CrioArray} the array with only truthy values
   */
  compact() {
    return this.filter((item) => {
      return !!item;
    });
  }

  /**
   * @function copyWithin
   *
   * @description
   * move values around within the array
   *
   * @param {number} targetIndex target to copy
   * @param {number} [startIndex=0] index to start copying to
   * @param {number} [endIndex=this.length] index to stop copying to
   * @returns {CrioArray} array with target copied in appropriate spots
   */
  copyWithin(targetIndex, startIndex = 0, endIndex = this.length) {
    const clone = [...this];
    const length = this.length >>> 0;

    let to = getRelativeValue(targetIndex >> 0, length),
      from = getRelativeValue(startIndex >> 0, length);

    const final = getRelativeValue(endIndex >> 0, length);

    let count = Math.min(final - from, length - to),
      direction = 1;

    if (from < to && to < from + count) {
      direction = -1;
      from += count - 1;
      to += count - 1;
    }

    while (count > 0) {
      if (from in clone) {
        clone[to] = clone[from];
      } else {
        delete clone[to];
      }

      from += direction;
      to += direction;
      count--;
    }

    return new this.constructor(clone);
  }

  /**
   * @function delete
   * @memberof CrioArray
   *
   * @description
   * delete the value in the array at key, either shallow or deep
   *
   * @param {Array<number|string>|number} key the key to delete
   * @returns {CrioArray} the array with the key deleted
   */
  delete(key) {
    return remove(key, this);
  }

  /**
   * @function difference
   * @memberof CrioArray
   *
   * @description
   * find the values in this that do not exist in any of the arrays passed
   *
   * @param {...Array<Array<*>>} arrays arrays to get the difference of
   * @returns {CrioArray} array of items matching diffference criteria
   */
  difference(...arrays) {
    if (!arrays.length) {
      return this;
    }

    let indexOfItem;

    return arrays.reduce((differenceArray, array) => {
      if (isArray(array)) {
        array.forEach((item) => {
          indexOfItem = differenceArray.indexOf(item);

          if (~indexOfItem) {
            differenceArray = differenceArray.splice(indexOfItem, 1);
          }
        });
      }

      return differenceArray;
    }, this);
  }

  /**
   * @function entries
   * @memberof CrioArray
   *
   * @description
   * get the pairs of [key, value] in the crio
   *
   * @returns {CrioArray} [key, value] pairs
   */
  entries() {
    return getEntries(this);
  }

  /**
   * @function equals
   * @memberof CrioArray
   *
   * @description
   * does the object passed equal the crio
   *
   * @param {*} object object to compare against the instance
   * @returns {boolean} is the object equivalent in value
   */
  equals(object) {
    return isEqual(this, object);
  }

  /**
   * @function fill
   *
   * @description
   * fill the array at certain indices with the value passed
   *
   * @param {*} value the value to fill the indices with
   * @param {number} [startIndex=0] the starting index to fill
   * @param {number} [endIndex=this.length] the ending index to fill
   * @returns {CrioArray} array with values filled appropriately
   */
  fill(value, startIndex = 0, endIndex = this.length) {
    const from = startIndex < 0 ? this.length + startIndex : startIndex;
    const to = endIndex < 0 ? this.length + endIndex : endIndex;
    const crioedValue = getCrioedObject(value);

    return this.map((item, index) => {
      return index >= from && index < to ? crioedValue : item;
    });
  }

  /**
   * @function findLast
   * @memberof CrioArray
   *
   * @description
   * find an item in the crio if it exists, starting from the end and iteratng to the start
   *
   * @param {function} fn function to test for finding the item
   * @returns {*} found item or undefined
   */
  findLast(fn) {
    return find(this, fn, false, true);
  }

  /**
   * @function findLastIndex
   * @memberof CrioArray
   *
   * @description
   * find the matching index based on truthy return from fn starting from end
   *
   * @param {function} fn function to use for test in iteration
   * @returns {number} index of match, or -1
   */
  findLastIndex(fn) {
    return find(this, fn, true, true);
  }

  /**
   * @function first
   * @memberof CrioArray
   *
   * @description
   * take the first n number of items in the array
   *
   * @param {number} [size=1] size of elements to take from beginning of array
   * @returns {CrioArray}
   */
  first(size = 1) {
    return this.slice(0, size);
  }

  /**
   * @function forEach
   * @memberof CrioArray
   *
   * @description
   * iterate over the array executing fn
   *
   * @param {function} fn the function to execute
   * @returns {CrioArray} the original array
   */
  forEach(fn) {
    Array.prototype.forEach.call(this, fn);

    return this;
  }

  /**
   * @function get
   * @memberof CrioArray
   *
   * @description
   * get the item at key passed, either shallow or deeply nested
   *
   * @param {Array<number|string>|number} key key to retrieve
   * @returns {*} item found at key
   */
  get(key) {
    return get(key, this);
  }

  /**
   * @function has
   * @memberof CrioArray
   *
   * @description
   * does the crio have the key passed, either shallow or deeply nested
   *
   * @param {Array<number|string>|number} key key to test
   * @returns {boolean} does the crio have the key
   */
  has(key) {
    return has(key, this);
  }

  /**
   * @function intersection
   * @memberof CrioArray
   *
   * @description
   * find the values in that exist in this and each of the arrays passed
   *
   * @param {...Array<Array>} arrays to find the intersecting values of
   * @returns {CrioArray} array of values that exist in all arrays
   */
  intersection(...arrays) {
    if (!arrays.length) {
      return this;
    }

    let indices = [],
      indexOfItem;

    const reducedArrays = [this, ...arrays].reduce((items, array) => {
      if (isArray(array)) {
        array.forEach((item) => {
          indexOfItem = items.indexOf(item);

          if (~indexOfItem) {
            return indices[indexOfItem]++;
          }

          indices[items.length] = 1;
          items.push(item);
        });
      }

      return items;
    }, []);

    const newLength = arrays.length + 1;

    return new CrioArray(
      reducedArrays.filter((itemIgnored, index) => {
        return indices[index] === newLength;
      })
    );
  }

  /**
   * @function isArray
   * @memberof CrioArray
   *
   * @description
   * is the crio an array
   *
   * @returns {boolean} is the crio an array
   */
  isArray() {
    return true;
  }

  /**
   * @function isObject
   * @memberof CrioArray
   *
   * @description
   * is the crio an object
   *
   * @returns {boolean} is the crio an object
   */
  isObject() {
    return false;
  }

  /**
   * @function join
   * @memberof CrioArray
   *
   * @description
   * join the values in the crio as a string, combined with separator
   *
   * @param {string} [separator] character(s) to place between strings in combination
   * @returns {string} parameters joined by separator in string
   */
  join(separator) {
    return this.thaw().join(separator);
  }

  /**
   * @function keys
   * @memberof CrioArray
   *
   * @description
   * get the keys of the crio
   *
   * @returns {CrioArray<number>} keys in the crio
   */
  keys() {
    return new CrioArray(Object.keys(this).map(Number));
  }

  /**
   * @function last
   * @memberof CrioArray
   *
   * @description
   * take the last n number of items in the array
   *
   * @param {number} [size=1] size of elements to take from end of array
   * @returns {CrioArray}
   */
  last(size = 1) {
    return this.slice(this.length - size);
  }

  /**
   * @function map
   * @memberof CrioArray
   *
   * @description
   * map over the array returning the mapped items
   *
   * @param {function} fn the function to map
   * @returns {CrioArray} the mapped array
   */
  map(fn) {
    return Array.prototype.map.call(this, (item, index) => {
      return getCrioedObject(fn(item, index, this));
    });
  }

  /**
   * @function merge
   * @memberof CrioArray
   *
   * @description
   * merge arrays with the original array
   *
   * @param {Array<number|string>|number|null} key the key to merge into
   * @param {...Array<CrioArray>} objects objects to merge with the crio
   * @returns {CrioArray} merged array
   */
  merge(key, ...objects) {
    return objects.reduce((mergedObject, object) => {
      return merge(key, getCrioedObject(object), mergedObject);
    }, this);
  }

  /**
   * @function mutate
   * @memberof CrioArray
   *
   * @description
   * work with the array in a mutated way and return the crioed result of that call
   *
   * @param {function} fn function to apply to crio
   * @returns {*} crioed value resulting from the call
   */
  mutate(fn) {
    return getCrioedObject(fn(this.thaw(), this));
  }

  /**
   * @function pluck
   * @memberof CrioArray
   *
   * @description
   * get the values in each object in the collection at key, either shallow or deeply nested
   *
   * @param {string} key key to find value of in collection object
   * @returns {CrioArray} array of plucked values
   */
  pluck(key) {
    const parsedKey = parse(key);

    const arrayToPluck = get(parsedKey.slice(0, parsedKey.length - 1), this);
    const finalKey = parsedKey.slice(-1);

    return arrayToPluck.map((item) => {
      return get(finalKey, item);
    });
  }

  /**
   * @function pop
   * @memberof CrioArray
   *
   * @description
   * get crio based on current crio with last item removed
   *
   * @returns {CrioArray} array with the last value removed
   */
  pop() {
    return this.slice(0, this.length - 1);
  }

  /**
   * @function push
   * @memberof CrioArray
   *
   * @description
   * push one to many items to the current crio
   *
   * @param {...Array<*>} items the items to add to the array
   * @returns {CrioArray} array with the values added
   */
  push(...items) {
    return items.length ? this.concat(items) : this;
  }

  /**
   * @function reduce
   * @memberof CrioObject
   *
   * @description
   * reduce the crio down to a single value, starting with initial value
   *
   * @param {function} fn the function to iterate with
   * @param {*} initialValue the initial value of the reduction
   * @returns {*} the reduced value
   */
  reduce(fn, initialValue) {
    return getCrioedObject(
      Array.prototype.reduce.call(
        this,
        (value, item, index) => {
          return fn(value, item, index, this);
        },
        initialValue
      )
    );
  }

  /**
   * @function reduceRight
   * @memberof CrioObject
   *
   * @description
   * reduce the crio down to a single value, starting with initial value, starting from the end of the array
   * and iterating to the start
   *
   * @param {function} fn the function to iterate with
   * @param {*} initialValue the initial value of the reduction
   * @returns {*} the reduced value
   */
  reduceRight(fn, initialValue) {
    return getCrioedObject(
      Array.prototype.reduceRight.call(
        this,
        (value, item, index) => {
          return fn(value, item, index, this);
        },
        initialValue
      )
    );
  }

  /**
   * @function reverse
   * @memberof CrioArray
   *
   * @description
   * get the same values, but in reverse order
   *
   * @returns {CrioArray} array with the items reversed in order
   */
  reverse() {
    return new CrioArray([...this].reverse());
  }

  /**
   * @function set
   * @memberof CrioArray
   *
   * @description
   * set the value at the key passed
   *
   * @param {Array<number|string>|number} key key to assign value to
   * @param {*} value value to assign
   * @returns {CrioArray} array with value set at key
   */
  set(key, value) {
    return set(key, getCrioedObject(value), this);
  }

  /**
   * @function shift
   * @memberof CrioArray
   *
   * @description
   * get crio based on current crio with first item removed
   *
   * @returns {CrioArray} array with the first item removed
   */
  shift() {
    return this.slice(1);
  }

  /**
   * @function sort
   * @memberof CrioArray
   *
   * @description
   * sort the collection by the fn passed
   *
   * @param {function} fn the function to sort based on
   * @returns {CrioArray} array with the items sorted
   */
  sort(fn) {
    const clone = [...this];

    clone.sort(fn);

    return new CrioArray(clone);
  }

  /**
   * @function splice
   * @memberof CrioArray
   *
   * @description
   * splice the values into or out of the array
   *
   * @param {number} [start=0] starting index to splice
   * @param {number} [deleteCount=1] length from starting index to removes
   * @param {...Array<*>} items items to insert after delete is complete
   * @returns {CrioArray} array with the value spliced in / out
   */
  splice(...args) {
    const clone = [...this];

    clone.splice(...args);

    return new CrioArray(clone);
  }

  /**
   * @function thaw
   * @memberof CrioArray
   *
   * @description
   * create a plain JS version of the array
   *
   * @returns {Array<*>} plain JS version of the array
   */
  thaw() {
    return thaw(this);
  }

  /**
   * @function toArray
   * @memberof CrioArray
   *
   * @description
   * convert the array to an array
   *
   * @returns {CrioArray} the array
   */
  toArray() {
    return this;
  }

  /**
   * @function toLocaleString
   * @memberof CrioArray
   *
   * @description
   * convert the array to stringified form
   *
   * @param {function} [serializer] the serialization method to use
   * @param {number} [indent] the number of spaces to indent the values
   * @returns {string} stringified array
   */
  toLocaleString(serializer, indent) {
    return this.toString(serializer, indent);
  }

  /**
   * @function toObject
   * @memberof CrioArray
   *
   * @description
   * convert the crio to an object if it isn't already
   *
   * @returns {CrioObject} new object from the array
   */
  toObject() {
    return this.reduce((object, item, index) => {
      object[index] = item;

      return object;
    }, new CrioObject({}));
  }

  /**
   * @function toString
   * @memberof CrioArray
   *
   * @description
   * convert the array to stringified form
   *
   * @param {function} [serializer] the serialization method to use
   * @param {number} [indent] the number of spaces to indent the values
   * @returns {string} stringified array
   */
  toString(serializer, indent) {
    return stringify(this, serializer, indent);
  }

  /**
   * @function unique
   * @memberof CrioArray
   *
   * @description
   * return the current CrioArray with the duplicate values removed
   *
   * @returns {CrioArray} new crio instance
   */
  unique() {
    let hashArray = [],
      newArray = [],
      hasHashCode = false,
      hashCode,
      storeValue;

    return this.filter((item) => {
      hashCode = item ? item.hashCode : undefined;
      hasHashCode = !isUndefined(hashCode);
      storeValue =
        !~newArray.indexOf(item) &&
        (!hasHashCode || !~hashArray.indexOf(hashCode));

      if (storeValue) {
        newArray.push(item);

        if (hasHashCode) {
          hashArray.push(hashCode);
        }
      }

      return storeValue;
    });
  }

  /**
   * @function unshift
   * @memberof CrioArray
   *
   * @description
   * add items passed to the beginning of the crio array
   *
   * @param {...Array<*>} items items to prepend to the array
   * @returns {CrioArray} array with the items prepended
   */
  unshift(...items) {
    return items.length ? new CrioArray([...items, ...this]) : this;
  }

  /**
   * @function valueOf
   * @memberof CrioArray
   *
   * @description
   * get the array value
   *
   * @returns {CrioArray} the array
   */
  valueOf() {
    return this;
  }

  /**
   * @function values
   * @memberof CrioArray
   *
   * @description
   * get the values of the array as an array
   *
   * @returns {CrioArray} values in the array
   */
  values() {
    return getValues(this);
  }

  /**
   * @function xor
   * @memberof CrioArray
   *
   * @description
   * find the values that are the symmetric difference of this and the arrays passed
   *
   * @param {Array<Array>} arrays arrays to find symmetric values in
   * @returns {CrioArray} array of the symmetric differences of all the arrays
   */
  xor(...arrays) {
    if (!arrays.length) {
      return this;
    }

    let indicesToRemove = [],
      indexOfItem;

    const reducedItems = [this, ...arrays].reduce((items, array) => {
      if (isArray(array)) {
        array.forEach((item) => {
          indexOfItem = items.indexOf(item);

          if (~indexOfItem) {
            indicesToRemove.push(indexOfItem);
          } else {
            items = items.push(item);
          }
        });
      }

      return items;
    }, new CrioArray([]));

    return reducedItems.filter((itemIgnored, index) => {
      return !~indicesToRemove.indexOf(index);
    });
  }
}

export function applyPrototype() {
  Object.keys(ARRAY_FALLBACK_PROTOTYPE_METHODS).forEach((key) => {
    if (typeof Array.prototype[key] !== 'function') {
      CrioArray.prototype[key] = function(...args) {
        return ARRAY_FALLBACK_PROTOTYPE_METHODS[key](this, ...args);
      };
    }
  });

  if (typeof Symbol === 'function') {
    if (Symbol.species) {
      Object.defineProperty(CrioArray, Symbol.species, {
        configurable: false,
        enumerable: false,
        get() {
          return CrioArray;
        }
      });
    }

    if (Symbol.iterator) {
      Object.defineProperty(CrioArray.prototype, Symbol.iterator, {
        configurable: false,
        enumerable: false,
        value: createIterator(),
        writable: false
      });
    }

    if (Symbol.unscopables) {
      Object.defineProperty(CrioArray.prototype, Symbol.unscopables, {
        configurable: false,
        enumerable: false,
        value: ARRAY_UNSCOPABLES,
        writable: false
      });
    }
  }
}

export default CrioArray;