baja/sys/bajaUtils.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/*global requirejs */

/**
 * Defines BajaScript utility functions {@link baja.strictArg}, 
 * {@link baja.strictAllArgs}, {@link baja.def}, {@link baja.objectify},
 * and {@link baja.iterate}.
 *
 * @module baja/sys/bajaUtils
 */
define([
  'bajaPromises',
  'bajaScript/baja/sys/structures/Cursor' ], function (
  bajaPromises,
  Cursor) {
  
  "use strict";

  const unescapeXmlMap = { apos: "'", quot: '"', lt: '<', gt: '>', amp: '&' };

  const getBaja = (function () {
    let baja;
    return function () {
      if (!baja) { baja = require('baja!'); }
      return baja;
    };
  }());

  let validateArgs = true;
  
  /**
   * Strict Argument Check.
   * 
   * Checks a given argument to ensure it's not undefined. 
   *
   * @private
   * @function baja.strictArg
   * 
   * @param arg  the argument being tested.
   * @param {Function|String|Type} [ctor] optional Constructor function used to test against arg.
   *                                      Can also be a String TypeSpec or a Type Object.
   * @param {String} [errMsg] optional message to specify if argument check fails.
   * @returns arg
   * @throws {Error} if the argument check fails.
   * 
   * @example
   *   <caption>
   *     If a constructor function is specified then it matches the 
   *     argument's constructor against it.
   *   </caption>
   * 
   *   baja.strictArg(arg1, String);
   *   
   * @example
   *   <caption>
   *     The second argument can also be a String TypeSpec or a Type Object. 
   *     This will ensure the argument has the correct TypeSpec.
   *   </caption>
   *   
   * baja.strictArg(arg1, "control:NumericWritable");
   * 
   * //...or...
   *
   * baja.strictArg(arg1, baja.lt("control:NumericWritable")); 
   */
  function strictArg(arg, ctor, errMsg) {
    if (!validateArgs) { return arg; }

    var argType,
        argCtor,
        ctorType = typeof ctor,
        ctorName,
        isType = true,
        isValid = false,
        err = "";

    // Null arguments are acceptable (just like Java)
    // Bail any type checking if we don't have a valid Constructor
    if (arg !== undefined &&
      (arg === null || ctor === undefined || ctor === null)) {
      return arg;
    }

    if (ctorType === "function") {

      if (arg !== undefined) {
        argCtor = arg.constructor;
        if (argCtor === ctor || arg instanceof ctor) {
          isValid = true;
        }
        argType = argCtor.name;
        if (!argType && argCtor.$name) {
          argType = argCtor.$name;
        }
      }

      ctorName = ctor.name;
      if (!ctorName && ctor.$name) {
        ctorName = ctor.$name;
      }
    }

    // If ctor is a String TypeSpec or Type Object
    if (ctorType === "string" || ctorType === "object") {

      if (arg !== undefined) {

        // TODO: remove this require() as part of completing modularization
        // of bajaScript.
        var baja = getBaja();

        if (!baja.hasType(arg)) {
          isType = false;
        }

        argType = arg.getType();
        ctor = baja.lt(ctor);
        if (argType.is(ctor)) {
          isValid = true;
        }
      }

      ctorName = String(ctor);
    }

    if (arg === undefined) {
      err = "Invalid argument (undefined)";
    } else {
      if (!isType) {
        err = "Invalid argument type (unrecognized)";
      } else if (!isValid) {
        err = "Invalid argument type (" + argType + ")";
      }
    }
    if (err) {
      if (ctorName) {
        err += ", expected " + ctorName;
      }
      // TODO: Missing tests for use of errMsg
      throw new Error(errMsg || err);
    }
    return arg;

  }
    
  /**
   * Strict Arguments Check.
   * 
   * Checks all of the given arguments to ensure they're not undefined. 
   *
   * @private
   * @function baja.strictAllArgs
   *
   * @see baja.strictArg
   *
   * @param {Array} args  an array of arguments being tested.
   * @param {Array} ctors  an array of Constructors being used to test against the arguments.
   * 
   * @example
   *   <caption>
   *     An array of constructors is passed in to ensure they match the 
   *     arguments' constructors. The constructors array can also hold a Type 
   *     used for comparison (in the form of either a String or Type).
   *   </caption>
   *   
   *   baja.strictAllArgs([arg1, arg2], [String, Number]);
   *   baja.strictAllArgs([arg3, arg4], ['baja:Double', baja.lt('baja:Component')]);
   */
  function strictAllArgs(args, ctors) {
    if (!validateArgs) { return; }

    if (ctors.length !== args.length) {
      throw new Error("Invalid number of arguments. Expected " + ctors.length +
          ", received " + args.length + " instead.");
    }
    
    var i;
    for (i = 0; i < args.length; i++) {
      strictArg(args[i], ctors[i]);
    }
  }

  /**
   * Ensures the arg is a `baja:Number`, then returns the Number value
   * in either case.
   *
   * @param {Number|baja.Simple} arg (any `baja:Number` type)
   * @param {String} [errMsg] optional Error message to return if any argument
   * check fails.
   *
   * @returns {Number}
   */
  function strictNumber(arg, errMsg) {
    var baja = getBaja();
    strictArg(arg, 'baja:Number', errMsg);
    if (baja.hasType(arg, 'baja:Number')) {
      arg = arg.valueOf();
    }
    return arg;
  }

  /**
   * Define a default value for possibly undefined variables.
   *
   * @private
   * @function baja.def
   *
   * @param val the value to be tested.
   * @param defVal the default value to be returned if the value is undefined.
   * @returns the default value if the value is undefined.
   */
  function def(val, defVal) {
    return val === undefined ? defVal : val;
  }
  
  /**
   * This is a convenience method used for working with functions that take 
   * an object literal as an argument.
   * 
   * This method always ensures an Object is returned so its properties can be
   * further validated.
   * 
   * In some cases, the function may take an object literal or a single 
   * argument. If the function can take a single argument that isn't an 
   * object literal, then the `propName` can be specified. If this is 
   * specified, an Object is created and the value is assigned to the 
   * Object with the specified property name.
   *
   * @function baja.objectify
   * @private
   *
   * @param obj  the Object literal or a value to be added onto an Object if propName is specified.
   * @param {String} [propName]  if the object isn't an Object, an Object is created and the value is assigned
   *                             to the object with this property name.
   *
   * @returns {Object} an Object
   * 
   * @example
   *   // This function can take an Object literal or a Number
   *   function foo(obj) {
   *     obj = baja.objectify(obj, "num");
   *     
   *     // 'obj' will always point to an Object. We can now test and work with 'num'...
   *     baja.strictArg(obj.num, Number);
   *   }
   *
   *   // Both method invocations are valid...
   *   foo(23.4); 
   *   foo({num: 23.4});

   */
  function objectify(obj, propName) {
    if (!(obj === undefined || obj === null)) {
      if (obj.constructor === Object) {
        return obj;
      }
      
      if (typeof propName === "string") {
        var o = {};
        o[propName] = obj;
        return o;
      }
    }
    
    return {};
  }
  
  function iterateArray(arr, start, end, func) {
    var i, result;

    for (i = start; i < end; i++) {
      result = func(arr[i], i);
      if (result !== undefined) {
        return result;
      }
    }
  }

  function iterateCustomNext(obj, func, nextFunc) {
    while (obj) {
      var result = func(obj);
      if (result !== undefined) {
        return result;
      }
      
      obj = nextFunc(obj);
    }
  }

  function iterateJsProperties(obj, func) {
    var name, result;
    for (name in obj) {
      if (obj.hasOwnProperty(name)) {
        result = func(obj[name], name);
        if (result !== undefined) {
          return result;
        }
      }
    }
  }

  function iterateByIndex(start, end, func) {
    var i, result;

    for (i = start; i < end; i++) {
      result = func(i);
      if (result !== undefined) {
        return result;
      }
    }
  }

  /**
   * A iteration general utility method that performs the given function on every JavaScript 
   * property in the given cursor or Javascript array. This function can be called 
   * with a variety of parameter configurations.
   * 
   * `iterate()` is compatible with arrays, but *not* with
   * `arguments` objects - pass in
   * `Array.prototype.slice.call(arguments)` instead.
   *
   * In the last case with the `doIterate` and 
   * `getNext` functions, `doIterate` performs the
   * iterative action on the object, and `getNext` returns the
   * next object to iterate (this will be passed directly back into
   * `doIterate`). This is handy for walking up prototype chains,
   * supertype chains, component hierarchies, etc.
   * 
   * In all cases, if the function being executed ever returns a value
   * other than `undefined`, iteration will be stopped at that 
   * point and `iterate()` will return that value.
   * 
   * For invocations of `iterate()` that include start or end
   * indexes, note that start indexes are inclusive and end indexes are
   * exclusive (e.g. `iterate(2, 5, function (i) { baja.outln(i); })`
   * would print `2,3,4`).
   * 
   * @function baja.iterate
   * @returns any non-undefined value that's returned from any function 
   * or Cursor.
   * 
   * @example
   * baja.iterate(array, function (arrayElement, arrayIndex))
   * 
   * baja.iterate(array, startIndex, function (arrayElement, arrayIndex))
   * 
   * baja.iterate(array, startIndex, endIndex, function (arrayElement, arrayIndex))
   * 
   * baja.iterate(numberOfTimesToIterate, function (index))
   * 
   * baja.iterate(iterationStartIndex, iterationEndIndex, function (index))
   *
   * baja.iterate(object, function (objectJsProperty, objectJsPropertyName))
   * 
   * baja.iterate(object, function doIterate(object), function getNext(object))
   */
  function iterate() {
    var args = arguments,
        arg0 = args[0],
        arg1 = args[1],
        arg2 = args[2],
        arg3 = args[3],
        typeString = Object.prototype.toString.call(arg0);

    if (arg0 === undefined || arg0 === null) {
      throw new Error("undefined passed to baja.iterate()");
    }

    if (typeString === '[object Array]') {
      switch (arguments.length) {
      case 2: //iterate(array, function (arrayElement, arrayIndex))
        return iterateArray(arg0, 0, arg0.length, arg1);

      case 3: //iterate(array, startIndex, function (arrayElement, arrayIndex))
        return iterateArray(arg0, arg1, arg0.length, arg2);

      case 4: //iterate(array, startIndex, endIndex, function (arrayElement, arrayIndex))
        return iterateArray(arg0, arg1, arg2, arg3);
      } 
    } else if (typeString === '[object Object]') {   
      if (arg0 instanceof Cursor) {
        //bajaScriptCursor.each(function ())
        return arg0.each(arg1);
      }      
      
      if (typeof arg2 === 'function') {
        //iterate(object, function doIterate(object), function getNext(object))
        return iterateCustomNext(arg0, arg1, arg2);
      }
      
      //iterate(object, function (objectJsProperty, objectJsPropertyName))
      return iterateJsProperties(arg0, arg1);
    }   
    
    if (typeString === '[object Number]') {
      if (typeof arg1 === 'number') {
        //iterate(iterationStartIndex, iterationEndIndex, function (index))
        return iterateByIndex(arg0, arg1, arg2);
      }
      
      //iterate(numberOfTimesToIterate, function (index))
      return iterateByIndex(0, arg0, arg1);
    }
    
    if (typeString === '[object Arguments]') {
      throw new Error("Arguments object not iterable (pass through " + 
        "Array.prototype.slice first)");
    }

    throw new Error(arg0 + " is not iterable");
  }

  /**
   * Unescape Xml characters out of a string.
   * Here are some unescape examples:
   * * '&amplt;' to <
   * * '&ampgt;' to >
   * * '&ampamp;' to &
   * * '&ampapos;' to '
   * * '&ampquot;' to "
   * * '&amp;#x20ac;' to €
   *
   * @function baja.unescapeXml
   * @param {String} [str]
   * @return {String}
   */
  function unescapeXml(str) {
    if (!str) {
      return str;
    }
    return str.replace(/&(#x([0-9a-fA-F]+)|(apos|quot|lt|gt|amp));/g, function (match, _, code, replace) {
      return code ? String.fromCharCode(parseInt(code, 16)) : unescapeXmlMap[replace];
    });
  }

  function doRequire(deps) {
    const df = bajaPromises.deferred();
    requirejs(deps, function () {
      df.resolve(Array.prototype.slice.call(arguments).map(function (arg) {
        if (arg && arg.__esModule) {
          return arg.default || arg;
        }
        return arg;
      }));
    }, df.reject);
    return df.promise();
  }

  function resolveDependencies(tree) {
    function resolveLayerAt(i) {
      const layer = tree && tree[i];
      return bajaPromises.resolve(layer && doRequire(layer).then(() => resolveLayerAt(i + 1)));
    }
    return resolveLayerAt(0);
  }
  
  return {
    def: def,
    doRequire,
    iterate: iterate,
    objectify: objectify,
    resolveDependencies,
    strictArg: strictArg,
    strictAllArgs: strictAllArgs,
    strictNumber: strictNumber,
    unescapeXml: unescapeXml,
    $setValidateArgs: (v) => { validateArgs = v; }
  };
});