baja/ord/SlotPath.js

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

/**
 * Defines {@link baja.SlotPath}.
 * @module baja/ord/SlotPath
 */
define([
  "bajaScript/sys",
  "bajaScript/baja/ord/OrdQuery" ], function (
  baja,
  OrdQuery) {
  
  "use strict";
  
  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      strictArg = baja.strictArg;
  
  function parseSlotPathBackup(slotPath) {
    var body = slotPath.getBody(),
        len = body.length,
        c0, c1, c2, i;
    
    for (i = 0; i < len; i += 3) {
      c0 = body.charAt(i);
      c1 = (i + 1 < len) ? body.charAt(i + 1) : -1;
      c2 = (i + 2 < len) ? body.charAt(i + 2) : "/";
      
      if (c0 !== ".") {
        return i;
      }
      
      if (c1 !== "." || c2 !== "/") {
        // Since we know c0 is a period ('.'), we can check to see
        // if that is a valid path name.  For SlotPath's, it
        // should always return false, so the SyntaxException
        // will be thrown.  But for subclasses (such as VirtualPath),
        // this may be a legal path name, so we don't want to throw
        // the Syntax Exception.
        if (slotPath.isValidPathName(c0)) {
          return i;
        }
          
        throw new Error("Expecting ../ backup");
      }
      
      slotPath.$backupDepth++;
    }
    
    return len; 
  }
  
  function parseSlotPathNames(slotPath, start) {    
    var body = slotPath.getBody(),
        len = body.length,
        c, // Character
        nm,  // Name
        i;
    
    if (start >= len) {
      return;
    }
    
    if (body.charAt(len - 1) === "/") {
      throw new Error("Invalid Slot Path - Trailing Slash");
    }
    
    for (i = start; i < len; ++i) {
      c = body.charAt(i);
      
      if (c === "/") {
        if (i === start) {
          throw new Error("Invalid Slot Path - Double Slashes");
        }
        nm = body.substring(start, i);
        
        if (!slotPath.isValidPathName(nm)) {
          throw new Error("Invalid name in path");
        }
        
        slotPath.$names.push(nm);
        start = i + 1;
      }
    }
  
    nm = body.substring(start, len);
    
    if (!slotPath.isValidPathName(nm)) {
      throw new Error("Invalid name in path");
    }
  
    slotPath.$names.push(nm);
  }  
  
  function parseSlotPath(slotPath) {
    slotPath.$names = [];
    slotPath.$abs = false;
    
    if (slotPath.getBody().length === 0) {
      return;
    }
    
    var s = 0, // Start
        c = slotPath.getBody().charAt(0);
        
    if (c === "/") {
      slotPath.$abs = true;
      s = 1;
    } else if (c === ".") {
      s = parseSlotPathBackup(slotPath);
    }
        
    parseSlotPathNames(slotPath, s);
  }
         
  /**
   * `SlotPath` is used for resolving `BValue`s using slot names.
   *
   * @class
   * @alias baja.SlotPath
   * @extends baja.OrdQuery
   *
   * @param {String} body  the body of the ORD scheme
   */  
  var SlotPath = function SlotPath(body) {
    callSuper(SlotPath, this, [ {
      scheme: baja.SlotScheme.DEFAULT,
      schemeName: "slot",
      body: strictArg(body, String)
    } ]);
    this.$abs = false;
    this.$names = [];
    this.$backupDepth = 0;
    parseSlotPath(this);
  };
  
  subclass(SlotPath, OrdQuery);
  
  /**
   * Make a Slot Path.
   *
   * @private
   *
   * @param {Object} body  the body.
   * @returns {baja.SlotPath} the new Slot Path.
   */
  SlotPath.prototype.makeSlotPath = function (body) {
    return new SlotPath(body);
  };
      
  /**
   * Return the SlotPath depth.
   *
   * @returns {Number} the SlotPath depth.
   */ 
  SlotPath.prototype.depth = function () {
    return this.$names.length;
  };
  
  /**
   * Return the SlotPath backup depth.
   *
   * @returns {Number} the SlotPath depth.
   */ 
  SlotPath.prototype.getBackupDepth = function () {
    return this.$backupDepth;
  };
  
  /**
   * Return the name at the given depth.
   *
   * @param {Number} depth  the specified depth for the name.
   *
   * @returns {String} the name at the specified depth.
   */ 
  SlotPath.prototype.nameAt = function (depth) {
    strictArg(depth, Number);
    return this.$names[depth];
  };
  
  /**
   * Return true if the SlotPath is absolute.
   *
   * @returns {Boolean} true if the SlotPath is absolute.
   */ 
  SlotPath.prototype.isAbsolute = function () {
    return this.$abs;
  };
            
  /**
   * Return whether the specified path name is valid.
   *
   * @param {String} pathName the path name to validate.
   *
   * @returns {Boolean} true if the slot name is valid.
   */ 
  SlotPath.prototype.isValidPathName = function (pathName) {
    return SlotPath.isValidName(pathName);
  };
  
  /**
   * Return whether the slot name is valid
   *
   * @param {String} nm the name to be validated.
   *
   * @returns {Boolean} true if the slot name is valid.
   */ 
  SlotPath.isValidName = function (nm) {
    return (/^([a-zA-Z$]([a-zA-Z0-9_]|(\$([0-9a-fA-F]{2}))|(\$u([0-9a-fA-F]{4})))*)$/).test(nm); 
  };
  
  /**
   * Verify whether the slot name is valid.
   *
   * @param {String} nm the name to be validated.
   *
   * @throws {Error} if the slot name isn't valid.
   */ 
  SlotPath.verifyValidName = function (nm) {
    if (!SlotPath.isValidName(nm)) {
      throw new Error("Illegal name for Slot: " + nm);
    }
  };
  
  // Converts a character
  function convertSlotPathChar(c) {
    var code = c.charCodeAt(0),
        hex = code.toString(16),
        buf = "$";
    
    if (code < 0x10) {
      buf += "0" + hex;
    } else if (code < 0x100) {
      buf += hex;
    } else if (code < 0x1000) {
      buf += "u0" + hex;
    } else {
      buf += "u" + hex;
    }
    return buf;
  }
  
  /**
   * Escape the string so it becomes a valid name for a slot.
   *
   * @see baja.SlotPath.unescape
   *
   * @param {String} str the string to be escaped.
   *
   * @returns {String} the escaped String.
   */ 
  SlotPath.escape = function (str) {    
    if (str.length === 0) {
      return str;
    }
        
    // Convert first character
    var res = str.charAt(0).replace(/[^a-zA-Z]/, function (c) {
      return convertSlotPathChar(c);
    });
    
    if (str.length > 1) {   
      // Convert everything after first character
      res += str.substring(1, str.length).replace(/[^a-zA-Z0-9_]/g, function (c) {
        return convertSlotPathChar(c);
      });
    }
    
    return res;
  };
  
  /**
   * Unescape the string so all escaped characters become readable.
   *
   * @see baja.SlotPath.escape
   *
   * @param {String} str the string to be unescaped.
   *
   * @returns {String} the unescaped String.
   */ 
  SlotPath.unescape = function (str) {
    //Note: Any changes made here need to be made to lex.unescape, also.
    //@see js.lex.unescape

    if (str.length === 0) {
      return str;
    }

    // Convert from $uxxxx
    str = str.replace(/\$u[0-9a-fA-F]{4}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(2, s.length), 16));
    });

    // Convert from $xx    
    str = str.replace(/\$[0-9a-fA-F]{2}/g, function (s) {
      return String.fromCharCode(parseInt(s.substring(1, s.length), 16));
    });
    
    return str;
  };

  /**
   * Sometimes a slot name will run through `escape` multiple times before
   * getting to your code, so calling `baja.SlotPath.unescape` will still give
   * you a string that's escaped and not very readable by humans.
   * 
   * This function will essentially run `unescape` multiple times until it stops
   * having any effect, removing all traces of `SlotPath.escape()`ing.
   * 
   * Note that while `unescape(escape(str)) === str`, the same does not hold
   * for `unescapeFully(escape(str))`, so do not use this function if you will
   * need to translate back to a valid `Slot` name.
   * 
   * @param str a slot name, potentially escaped multiple times
   * @since Niagara 4.4
   * @returns {String} a fully unescaped, human readable slot name
   */
  SlotPath.unescapeFully = function (str) {
    return SlotPath.unescape(str.replace(/\$(24)+/g, '$'));
  };

  /**
   * Merge this path with the specified path.
   *
   * @param {baja.SlotPath} a
   * @returns {String} the body of the SlotPath.
   */ 
  SlotPath.prototype.merge = function (a) {
    // if absolute then return a
    if (a.isAbsolute()) {
      return a.getBody();
    }

    // otherwise we have no backup or a backup 
    // contained within my path
    var s = "",
        backups,
        i, 
        needSlash = false;
    
    if (this.isAbsolute()) { 
      s += "/";
    }

    // if the backup is past me
    if (a.getBackupDepth() > 0 && a.getBackupDepth() > this.depth()) {
      // can't handle backup past absolute root
      if (this.isAbsolute()) {
        throw new Error("Invalid merge " + this + " + " + a); 
      }        
      
      backups = a.getBackupDepth() - this.depth() + this.getBackupDepth();
      
      for (i = 0; i < backups; ++i) {
        s += "../";
      }
    }

    // Need to handle the case where this relative SlotPath contains a backup (that hasn't
    // already been accounted for above). For example, when "slot:../../a/b/c" is followed by
    // "slot:d/e/f", it needs to merge to "slot:../../a/b/c/d/e/f".
    if (this.getBackupDepth() > 0 && s.length === 0) {
      for (i = 0; i < this.getBackupDepth(); ++i) {
        s += "../";
      }
    }

    // add my path minus backup

    for (i = 0; i < this.depth() - a.getBackupDepth(); ++i) {
      if (needSlash) {
        s += "/";
      } else {
        needSlash = true;
      }
      s += this.nameAt(i);
    }

    // now add relative path
    for (i = 0; i < a.depth(); ++i) {
      if (needSlash) {
        s += '/'; 
      } else {
        needSlash = true;
      }
      s += a.nameAt(i);
    }
      
    return s;
  };
  
  /**
   * Normalize the ORD Query list.
   *
   * @private
   *
   * @param {baja.OrdQueryList} list  the ORD Query List.
   * @param {Number} index  the ORD Query List index.
   * @returns {Boolean} true if the list was modified.
   */
  SlotPath.prototype.normalize = function (list, index) {
    var current = list.get(index),
        next = list.get(index + 1),
        modified = false,
        currentSlotPath,
        newSlotPath;
    
    // Merge two Slot paths together
    if (next && next.getSchemeName() === current.getSchemeName()) {
    
      // Merge the slot paths together
      currentSlotPath = this.makeSlotPath(current.getBody());
      newSlotPath = this.makeSlotPath(currentSlotPath.merge(this.makeSlotPath(next.getBody())));
      
      // Update the OrdQueryList
      list.set(index, newSlotPath);
      
      // Remove the next item from the list
      list.remove(index + 1);
      
      modified = true;
    }
    
    return modified;
  };

  /**
   * Return all of the names for this SlotPath
   *
   * @returns {Array<String>} the names
   */   SlotPath.prototype.getNames = function () {
    return this.$names.slice();
  };
  
  return SlotPath;
});