package nxm.sys.lib;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Arrays;
import java.util.Map;
import java.util.regex.Pattern;

import nxm.sys.inc.AsciiMap;
import nxm.sys.inc.InternalUseOnly;

/**
  Utility class for the manipulation of filenames. Methods are provided for
  combining path components into filenames, translating between different OS
  path separators, etc.

  Filenames within Midas can contain far more information than simply the
  path to the file. Midas filenames also contain "qualifiers" and "trimmers"
  which are indicated by curly braces and parentheses respectively.

  Some examples of strings that are suitable file names:


  Filename that doesn't contain any qualifiers or trimmers.
  <blockquote> nxm/sys/dat/world.prm </blockquote>

  Filename that contains qualifiers.
  <blockquote> nxm/sys/dat/world.prm{PS=1K} </blockquote>

  Filename that contains trimmers.
  <blockquote> nxm/sys/dat/world.prm(0:1M) </blockquote>

  Filename that contains both trimmers and qualifiers.
  <blockquote> nxm/sys/dat/world.prm(0:1M){PS=1K} </blockquote>
*/
public class FileName implements AsciiMap,Cloneable {
  /** File Name case options.
      <pre>
        LowerCase   - Convert to lower case (like X-Midas).
        KeepCase    - Keep case exactly as-is.
        AutoCase    - Follow the NeXtMidas 3.2 (and earlier) case-fixing rules
                      that converted most all-upper-case names to lower-case and
                      kept the remainder as-is.
      </pre>
   */
  public static enum FNCase {
      /** convert FileName to lowercase */
      LowerCase,
      /** keep current FileName case */
      KeepCase,
      /** Follow NeXtMidas 3.2 and earlier case-fixing rules */
      AutoCase
    };

  private static       FNCase  defaultFNCase = FNCase.AutoCase;
  private static final Pattern DOT_PATTERN   = Pattern.compile("[.]");

  /** OS type MNEMONICS used as the path syntax indicator */
  public static final int OS_UNKNOWN=-1,OS_LOCAL=1,OS_UNIX=2,OS_DOS=3,OS_VMS=4;

  /** The local path separator (DOS, UNIX or VMS) */
  public static final String LOCAL_PATH_DELIM = File.separator,
                             DOS_PATH_DELIM   = "\\",
                             UNIX_PATH_DELIM  = "/",
                             VMS_PATH_DELIM   = ".";

  private String path;       // File path
  private String root;       // File root (no extension)
  private String ext;        // File extension
  private String tag;        // File tag  (used with PLOT)
  private transient String fullname; // Full name (path+root+ext)
  private String trimmers;   // File trimmers   (un-parsed)
  private String qualifiers; // File qualifiers (un-parsed)
  private transient Table qualTable; // File qualifiers (parsed)

  //////////////////////////////////////////////////////////////////////////////
  // CONSTRUCTORS USING DEFAULT CASE
  //////////////////////////////////////////////////////////////////////////////

  /** Initialize a new instance according to the filename given.
      @param filename The file name.
   */
  public FileName (String filename) {
    this(filename, null);
  }

  /** Constructor for a file name that takes a path, root and extension.
      This is the same as <code>FileName(inpath,inroot,inext,null)</code>.
      @param inpath The directory components of the path.
      @param inroot The root name of the file within its directory.
      @param inext The extension of the file.
  */
  public FileName (String inpath, String inroot, String inext) {
    this(inpath,inroot,inext,null);
  }

  /** Constructor for a file name that takes an array of path name strings,
      a single root and extension. This is the same as
      <code>FileName(pathList,inroot,inext,false)</code>.
      @param pathList The array of strings making up the path list. To make the
                      path each of these will be joined by inserting the
                      appropriate platform-dependent file separator.
      @param inroot   The root name of the file within its directory.
      @param inext    The extension of the file.
   */
  public FileName (String pathList[], String inroot, String inext) {
    this (pathList,inroot,inext,false);
  }

  //////////////////////////////////////////////////////////////////////////////
  // CONSTRUCTORS USING FNCase
  //////////////////////////////////////////////////////////////////////////////
  /** Initialize a new instance according to the filename given.
      @param filename The file name.
      @param fnCase   The file name case options (null = use default from
                      {@link #getDefaultFNCase()}).
      @param resolve  Resolve paths? (InternalUseOnly: Only set to false while
                      booting up shell.)
      @since NeXtMidas 3.3.1
   */
  @InternalUseOnly
  private FileName (String filename, FNCase fnCase, boolean resolve) {
    parseName(filename, fnCase, resolve);
  }

  /** Initialize a new instance according to the filename given.
      @param filename The file name.
      @param fnCase   The file name case options (null = use default from
                      {@link #getDefaultFNCase()}).
      @since NeXtMidas 3.3.1
   */
  public FileName (String filename, FNCase fnCase) {
    this(filename, fnCase, true);
  }

  /** Constructor for a file name. Takes a path, root and extension. <br><br>
      This is the same as <code>FileName(context,fname,false)</code>.
      @param inpath   The directory components of the path.
      @param inroot   The root name of the file within its directory.
      @param inext    The extension of the file.
      @param fnCase   The file name case options (null = use default from
                      {@link #getDefaultFNCase()}).
      @since NeXtMidas 3.3.1
  */
  public FileName (String inpath, String inroot, String inext, FNCase fnCase) {
    this(addPathRootExt(inpath,inroot,inext), fnCase);
  }

  //////////////////////////////////////////////////////////////////////////////
  // MISC. CONSTRUCTORS
  //////////////////////////////////////////////////////////////////////////////
  /** Initialize a new instance according to the given {@link File}.
      @param file The file.
      @since NeXtMidas 2.5.0
   */
  public FileName (File file) {
    this(file.toString(), FNCase.KeepCase);
  }

  //////////////////////////////////////////////////////////////////////////////
  // CONSTRUCTORS USING keepCase (most from 1.9.2, superceded in 3.3.1)
  //////////////////////////////////////////////////////////////////////////////
  /** Initialize a new instance according to the filename given.  The filename
      can be in any of the supported OS formats (DOS, UNIX, or VMS).
      @param filename The file name.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false the default conversion is used
                      (see {@link #getDefaultFNCase()}).
      @since NeXtMidas 1.9.2
   */
  public FileName (String filename, boolean keepCase) {
    this(filename, (keepCase)? FNCase.KeepCase : null);
  }

  /** Create a new filename that is relative to the existing context.
      @param context  The context for the file name. The file name is referenced
                      relative to this.
      @param fname    The file name.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false any part of a file name that is
                      in all upper case will be converted to lower case.
      @since NeXtMidas 1.9.2
   */
  public FileName (FileName context, String fname, boolean keepCase) {
    this(context._relativeTo(fname).getFullName(), keepCase);
  }

  /** Constructor for a file name. Takes a path, root and extension. <br><br>
      This is the same as <code>FileName(context,fname,false)</code>.
      @param inpath   The directory components of the path.
      @param inroot   The root name of the file within its directory.
      @param inext    The extension of the file.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false the default conversion is used
                      (see {@link #getDefaultFNCase()}).
      @since NeXtMidas 1.9.2
  */
  public FileName (String inpath, String inroot, String inext, boolean keepCase) {
    this(addPathRootExt(inpath,inroot,inext), (keepCase)? FNCase.KeepCase : null);
  }

  /** Constructor for a file name.  Takes an array of path name strings,
      a single root and extension.
      @param pathList The array of strings making up the path list. To make the
                      path each of these will be joined by inserting the
                      appropriate platform-dependent file separator (cannot be null or empty)
      @param inroot   The root name of the file within its directory.
      @param inext    The extension of the file.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false any part of a file name that is
                      in all upper case will be converted to lower case.
      @since NeXtMidas 1.9.2
   */
  public FileName (String pathList[], String inroot, String inext, boolean keepCase) {
    FNCase fnCase = (keepCase) ? FNCase.KeepCase : null;
    boolean isClasspath = (pathList[0].equalsIgnoreCase("classpath:"));
    int startPos = 0;

    // find out start position, if it is classpath it will start at index 2 vs 1
    // so it will drop the "classpath:".
    if (isClasspath && pathList.length > 1) {
      startPos = 2;
    }
    else {
      startPos = 1;
    }

    String inpath = pathList[startPos - 1];

    for (int i = startPos; i < pathList.length; i++) {
      inpath = addPathDir(inpath, pathList[i]);
    }
    if (isClasspath) { // since 3.8.0 to support "classpath:..." paths
      // load the file from the jar. This will be null if it does not exist.
      URL fileURL = Thread.currentThread().getContextClassLoader().getResource(inpath);
      if (fileURL != null) {
        inpath = "jar:" + fileURL.getPath();
      }
      else {
        // set the path back to classpath, is the the best answer?
        inpath = pathList[0];
      }
    }

    parseName(addPathRootExt(inpath, inroot, inext), fnCase, true);
  }

  //////////////////////////////////////////////////////////////////////////////
  // PARSING FUNCTIONS
  //////////////////////////////////////////////////////////////////////////////
  /** Parses the file name into the various class members.
      @param filename Full file name with optional delimiters, qualifiers and trimmers.
      @deprecated Since NeXtMidas 3.3.1 -- This is a legacy function that
                  was intended for internal use only. Please use
                  <code>new FileName(filename,FNCase.AutoCase)</code> in place of this.
  */
  @InternalUseOnly
  @Deprecated
  public void parseName (String filename) {
    parseName(filename,false);
  }

  /** Parses the file name into the various class members.
      @param filename Full file name with optional delimiters, qualifiers and trimmers.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false any part of a file name that is
                      in all upper case will be converted to lower case.
      @since NeXtMidas 1.9.2
      @deprecated Since NeXtMidas 3.3.1 -- This is a legacy function that
                  was intended for internal use only. Please use
                  <code>new FileName(filename,FNCase.KeepCase)</code> or
                  <code>new FileName(filename,FNCase.AutoCase)</code> in place of this.
  */
  @InternalUseOnly
  @Deprecated
  public void parseName (String filename, boolean keepCase) {
    parseName(filename, (keepCase)? FNCase.KeepCase : FNCase.AutoCase, true);
  }

  /** Reset all values to their defaults. */
  private void reset () {
    // The four that reset to "" matches NeXtMidas 3.2 and earlier.
    path       = "";
    root       = "";
    ext        = "";
    tag        = "";
    fullname   = null;
    qualifiers = null;
    trimmers   = null;
    qualTable  = null;
  }

  /** Parses the file name into the various class members.
      @param filename Full file name with optional delimiters, qualifiers and trimmers.
      @param keepCase Indicates the original case of the file name should be
                      kept. If this is false any part of a file name that is
                      in all upper case will be converted to lower case.
      @param resolve  Resolve paths? (InternalUseOnly: Only set to false while
                      booting up shell.)
      @since NeXtMidas 3.3.1
   */
  @InternalUseOnly
  private void parseName (String filename, FNCase fnCase, boolean resolve) {
    reset();
    if (filename==null || filename.isEmpty() || filename.equals("NULL")) {
      return;
    }

    // ==== REMOVE QUOTES ======================================================
    boolean quoted = (filename.charAt(0)==QUOTE);
    if (quoted) {   // strip quotes
      int i = filename.lastIndexOf(QUOTE);
      if (i<=0) i=filename.length();
      filename = filename.substring(1,i);
    }

    // ==== STRIP OFF TRIMMERS, QUALIFIERS =====================================
    int iQual = getQualifiersIndex(filename); // file qualifiers
    if (iQual >= 0) {
      qualifiers = filename.substring(iQual);
      filename   = filename.substring(0,iQual);
    }
    int iTrim = getTrimmersIndex(filename); // file trimmers
    if (iTrim >= 0) {
      trimmers = filename.substring(iTrim);
      filename = filename.substring(0,iTrim);
    }
    int iSemi = filename.lastIndexOf(SCOLON); // VMS version number
    if (iSemi>=0) {
      filename = filename.substring(0,iSemi);
    }

    // ==== LOOK FOR PATH SEPARATOR ============================================
    int urlSep = filename.indexOf("://");
    int iSlash = filename.lastIndexOf(SLASH);
    if (urlSep>0 && iSlash<urlSep+3) iSlash=-1;  // not the slash in the URL separator
    if (iSlash<0) iSlash = filename.lastIndexOf(BSLASH);
    if (iSlash<0) iSlash = filename.lastIndexOf(RBRACE);
    if (iSlash<0) iSlash = filename.lastIndexOf(COLON);
    int iBang = filename.lastIndexOf('!'); // separates tar/jar name from enclosed file
    if (iBang > iSlash) {
      iSlash = iBang;
    }
    if (urlSep>0 && iSlash<urlSep+3) iSlash=-1; // ignore chars in URL tag
    if (iSlash<0 && urlSep>0) iSlash=urlSep+2; // just URL tag

    // ==== PARSE FILE NAME (AND "nxm." SYNTAX) ================================
    String filenameLC = filename.toLowerCase();
    int    lastDot    = filename.lastIndexOf('.');

    if ((iSlash < 0) && filenameLC.startsWith("nxm.aux.") && (lastDot > 9)) {
      // This was never properly documented and as of NeXtMidas 3.3.1 was never
      // included in any of the NeXtMidas baseline tests.
      Shell.deprecate("Since NeXtMidas 3.3.1: Use of nxm.aux. syntax for files "
                     +"is deprecated (given '"+filename+"'), use AUX= qualifier "
                     +"in place of this.");
      int ii = filename.indexOf('.', 8);
      if (ii > 0) {
        path = (resolve)? Shell.getAuxPath(filename.substring(8,ii)) : "";
        root = (lastDot > ii)? filename.substring(ii+1,lastDot) : filename.substring(lastDot+1);
        ext  = (lastDot > ii)? filename.substring(lastDot+1)    : "";
      }
    }
    else if ((iSlash < 0) && filenameLC.startsWith("nxm.") && (lastDot > 4)) {
      // The nxm dot filename syntax: nxm.<opt>.<area>.[subdir.*]<file>[.<ext>]
      // Since 3.3.1: added support for optional [subdir.*] component in the nxm dot syntax,
      //              BUT it requires extension to be specified.
      String[] parts = DOT_PATTERN.split(filename);

      if (parts.length == 4) {
        parts = Arrays.copyOf(parts, 5);
        parts[4] = "";
      }
      path = (resolve)? Shell.getOptPath(parts[1], null) : ""; // null for AREA to allow access to nxm.<opt>.fileName.ext in the future
      root = parts[parts.length-2];
      ext  = parts[parts.length-1];

      if (resolve) {
        for (int n = 2; n < parts.length-2; n++) {
        String subdir = parts[n].toLowerCase(); // directories MUST always be in lower-case for nxm dot syntax (BUG 2768)
        path = addPathDir(path, subdir);        // allow sub dirs (since 3.3.1)
        }
      }
    }
    else {
      path = (iSlash>=0)? filename.substring(0,iSlash+1) : "";
      int j = filename.lastIndexOf(PERIOD);
      if (j>=0 && j>iSlash+1) {
        // require 1 character between the last SLASH and the last DOT for the
        // portion beyond the DOT to be considered the extension
        // Since 3.7.3 for NXM-3095 and NXM-3113
        root = filename.substring(iSlash+1,j);
        ext  = filename.substring(j+1);
      }
      else {
        root = filename.substring(iSlash+1);
        ext  = "";
      }
    }

    // ==== PARSE QUALIFIERS/TRIMMERS ==========================================
    // This must come after 'root' is set
    if (qualifiers != null) qualifiers = Parser.toUpperCaseWithQuotes(qualifiers);
    if (trimmers   != null) trimmers   = Parser.toUpperCaseWithQuotes(trimmers);
    if (tag==null||tag.isEmpty()) tag = root.toUpperCase();

    // ==== HANDLE CASE-CONVERSION =============================================
    // Qualifiers must be set first
    if (fnCase == null) fnCase = getDefaultFNCase();
    Object fnCaseQual = getQualifier("FNCASE");
    if (fnCaseQual != null) {
      fnCase = Util.valueOf(FNCase.class, fnCaseQual.toString());
    }
    else if (fnCase == FNCase.AutoCase) {
      // The "/RPF/" is old work-around for MAP option tree
      if (quoted || path.endsWith("/RPF/")) {
        fnCase = FNCase.KeepCase;
      }
    }
    switch (fnCase) {
      case LowerCase:
        path = path.toLowerCase();
        root = root.toLowerCase();
        ext  = ext.toLowerCase();
        break;
      case KeepCase:
        break;
      case AutoCase:
        path = autoCase(path);
        root = autoCase(root);
        ext  = autoCase(ext);
        break;
      default:
        throw new AssertionError("Unknown option "+fnCase);
    }
  }

  /** if all UpperCase return all LowerCase, else return as is */
  private static String autoCase (String name) {
    for (int i=0; i<name.length(); i++) {
      char c = name.charAt(i);
      if (c>=AsciiMap.LOWERA && c<=AsciiMap.LOWERZ) return name;
    }
    return name.toLowerCase();
  }

  /** Return the full name (path, filename and extension) as specified. This does NOT
      resolve the AUX used (e.g. "myfile.prm{AUX=HOME}" becomes "myfile.prm").
      The {@link #toURL()} and {@link #toLocalFileName()} functions will resolve
      the AUX (e.g. "myfile.prm{AUX=HOME}" becomes "file:///home/username/myfile.prm"
      or "/home/username/myfile.prm").
      @return full name
   */
  public String getFullName () {
    if (fullname==null) {
      fullname = path+root;
      if (ext.length()>0) fullname+="."+ext;
    }
    return fullname;
  }

  /** Get the full name with trimmers and qualifiers
   *  @return the full name with trimmers and qualifiers */
  public String getAllName () {
    String s = getFullName();
    if (hasTrimmers()) s += getTrimmers();
    if (hasQualifiers()) s += getQualifiers();
    return s;
  }

  /** Get the path component of the current filename
   *  @return the path component of the current filename. */
  public String getPath () { return path; }
  /** Set the file path
   *  @param value the file path
   */
  public void setPath (String value) { path=value; fullname=null; }

  /** Return a string array containing the file's path elements using the
      local system's path delimiter.
      @return string array containing the file's path elements */
  public String[] getPathElements () {
    String fname = getFullName();
    String delim = "/";
    if (fname.indexOf(BSLASH)>=0) delim = "\\";
    return StringUtil.cleanSplit(fname, delim);
  }

  /** Get the filename root (without extension) from a full path filename -or-
      from the NeXtMidas (Java like) NXM dot syntax (with filename extension!).
      NOTE1: This assumes the input filename has NO trimmers and qualifiers.
      NOTE2: path should have consistent path separators (behavior is undefined otherwise).
      For example:
      <pre>
        getRoot("myfile.prm")                    = myfile
        getRoot("/home/smith/myfile")            = myfile
        getRoot("/home/smith/myfile.prm")        = myfile
        getRoot("\data\myfile.tmp")              = myfile
        getRoot("home/smith/MyFile.abc")         = MyFile
        getRoot("file://home/smith/myfile.txt")  = myfile
        getRoot("http://server/data/myfile.tbl") = myfile
        getRoot("nxm.sys.test.MyFile.xyz")       = MyFile
        getRoot("nxm.sys.test.sub.myFILE.dat")   = myFILE
        getRoot("nxm.sys.test.zzz")              = test   (extension here is "zzz")
        getRoot("/home/smith/mydir/")            = "" (empty string, this is a directory)
        getRoot("file:/c:\home\myfile.prm")      = (INVALID! output is undefined!)
      </pre>
     @param path full path to filename -or- "nxm" dot syntax (with file extension)
     @return root of filename with no extension. null if path is null.
             Empty string if path ends with separator char (e.g. it is a directory).
     @see #getBasename(String)
     @see #getExt(String)
     @since NeXtMidas 3.1.2
   */
  public static String getRoot (String path) {
//    return (path == null)? null : (new FileName(path,true)).getRoot();
    if (path == null) return path;
    String root = getBasename(path);

    int ii = root.lastIndexOf('.'); // find last dot to get filename extension
    if (ii >= 0) root = root.substring(0, ii);

    return root;
  }

  /** Return the root name of the current filename.  The root name of a
      filename is the component following the path but preceding the extension.
      @return The string containing the file's root name.
  */
  public String getRoot() { return root; }

  /** Set the root name of the instance. This will change the file that this
      instance is pointing to.
      @param value The new root name for the instance.
   */
  public void setRoot (String value) { root=value; fullname=null; }


  /** Return the final extension of the given pathname. The extension is
      defined as everything following the final period ('.') in the name.
      NOTE: This assumes the input filename has NO trimmers and qualifiers.
      In 3.1.2:
        This returns an empty string ("") if filename has NO extension to match behavior of
          {@link #getExt()} &amp; {@link #getRoot()} (after {@link #parseName(String)}),
          {@link #getBasename()}, {@link #getRoot(String)}, etc. Previously, this would
          have return back null.
        Also, if name is null, then null is returned. Previously, this would have
          caused a NullPointerException.
        Use {@link #getExtLegacy(String)} for previous behavior.

      @param name The name of the file to return the extension for.
      @return The file extension or empty string ("") if it does not have one or
              null if name is null.
      @see #getBasename(String)
      @see #getRoot(String)
  */
  public static String getExt (String name) {
//    return (name == null)? null : (new FileName(name, true)).getExt();
    if (name == null) return name;
    int pos = name.lastIndexOf('.');
    return pos >= 0 ? name.substring(pos + 1) : "";
  }

  /** @param name filename
      @return The file extension or empty string ("") if it does not have one.
      @deprecated Since it was initial added in NeXtMidas 3.1.2
   */
  @Deprecated
  public static String getExtLegacy (String name) {
    int pos = name.lastIndexOf('.');
    return pos >= 0 ? name.substring(pos + 1) : null;
  }

  /** Get the extension of the current filename
   *  @return the extension of the current filename. */
  public String getExt() { return ext; }

  /** Sets the extension of the current filename.
   *  @param value the extension of the current filename.
   */
  public void setExt (String value) { ext=value; fullname=null; }

  /** Get the basename (filename root + extension, i.e. no path) from a file path -or-
      from the NeXtMidas (Java like) NXM dot syntax (with filename extension!).
      NOTE1: This assumes the input filename has NO trimmers and qualifiers.
      NOTE2: Path should have consistent path separators (behavior is undefined otherwise).
      NOTE3: The exclamation (!) character can not be used as a character in file names because
             it is used as a delimiter in tar/jar resources.
      For example:
      <pre>
        getBasename("myfile.prm")                    = myfile.prm
        getBasename("/home/smith/myfile")            = myfile
        getBasename("/home/smith/myfile.prm")        = myfile.prm
        getBasename("\data\myfile.tmp")              = myfile.tmp
        getBasename("home/smith/MyFile.abc")         = MyFile.abc
        getBasename("file://home/smith/myfile.txt")  = myfile.txt
        getBasename("http://server/data/myfile.tbl") = myfile.tbl
        getBasename("nxm.sys.test.MyFile.xyz")       = MyFile.xyz
        getBasename("nxm.sys.test.sub.myFILE.dat")   = myFILE.dat
        getBasename("nxm.sys.test.zzz")              = test.zzz
        getBasename("myjar.jar!/dir/myfile.class")   = myfile.class
        getBasename("tar:file:nxm.sys.test.mytar.tar!myfile.prm") =  "myfile.prm"
        getBasename("/home/smith/mydir/")            = "" (empty string, this is a directory)
        getBasename("file:/c:\home\myfile.prm")      = (INVALID! output is undefined!)
      </pre>
     @param path full path to filename -or- "nxm" dot syntax (with file extension)
     @return basename (root + ext) of filename. null if path is null.
             Empty string if path ends with separator char (e.g. it is a directory).
     @see #getRoot(String)
     @see #getExt(String)
     @since NeXtMidas 3.1.2
   */
  public static String getBasename (String path) {
    // This method must be stand alone (Must not call parseName or create a new FileName)
    if (path == null) return path;
    String basename = path;
    int ii = path.lastIndexOf('/');               // check for Unix/Web style paths
    if (ii < 0) ii = path.lastIndexOf('\\');      // otherwise check for Windows style paths

    int iBang = path.lastIndexOf('!'); // separates tar/jar name from enclosed file
    if (iBang > ii) {
      ii = iBang;
    }

    if (ii >=0 ) basename = path.substring(ii+1); // trim head off path (keeping root+ext)
    else if (path.startsWith("nxm.") || path.startsWith("NXM.")) {
      // we have NXM dot syntax (nxm.<opt>.<area>.<filename>.<ext>, etc.)
      int iLastDot = path.lastIndexOf('.');   // find last dot
      ii = path.lastIndexOf('.', iLastDot-1); // next 2nd to last dot
      if (ii >= 0) basename = path.substring(ii+1);
      return basename;
    }
    return basename;
  }

  /** Return the basename of the current filename.  The base name of a
      filename is the root name with file extension (i.e. no path).
      @return The string containing the file's basename.
      @since NeXtMidas 3.1.1
  */
  public String getBasename () {
    return root + ((ext!=null && ext.length()>0)? '.'+ext : "");
  }

  /** Get the tag for the current file name
   * @return the tag for the current filename. */
  public String getTag () { return tag; }

  /** Sets the tag for the current filename.
   *  @param value the tag for the current filename.
   */
  public void setTag (String value) { tag=value; }

  /** Does the file name have any qualifiers?
   *  @return Does the file name have any qualifiers (non empty)? */
  public boolean hasQualifiers () { return !getQualifierTable().isEmpty(); }

  /** Gets the qualifiers of the current filename as a String. Prior to NeXtMidas
      3.3.1 this (usually) returned the un-parsed version of the qualifiers
      string -- unfortunately this was could occasionally return a value that
      was inconsistent with {@link #getQualifierTable()}. Since NeXtMidas 3.3.1
      this always returns the string form of {@link #getQualifierTable()}, except
      where the qualifiers are empty, in which case this returns null.
      @return qualifiers
   */
  public String getQualifiers () {
    Table quals = getQualifierTable();
    return (quals.isEmpty())? null : quals.toString();
  }

  /** Return the parsed qualifiers of the current filename.
      Note: prior to NeXtMidas 3.1.0, this returned null vs an empty Table
            when no qualifiers are specified. See BUG 2497 for details.
      @return Table object (empty Table if there are no qualifiers specified).
   */
  public Table getQualifierTable () {
    if (qualTable!=null) return qualTable;
    if (qualifiers==null) {
      return (qualTable = new Table()); // empty Table (instead of null) since 3.1.0 for BUG 2497
    }
    if (qualifiers.charAt(0)==LCURLY) qualTable = new Table(qualifiers);
    else if (qualifiers.charAt(0)==LPAREN) {  // search for old style qualifiers
      qualTable = new Table();
      for (int pos=0;;) {
        int i = qualifiers.indexOf(LPAREN,pos);
        if (i<0) break;
        int j = qualifiers.indexOf(EQUALS,i);
        int k = StringUtil.getClosingFensePos(qualifiers, "()", i);
        if (j>i && j<k) qualTable.put(qualifiers.substring(i+1,j),qualifiers.substring(j+1,k));
        pos = k;
      }
    }
    return qualTable;
  }

  /** Set the qualifiers for the instance. The parameter is expected to be the
      full qualifiers string, complete with the open and close brackets. This
      will overwrite any previous qualifiers. Note that this is not 100%
      reflexive with {@link #getQualifiers()} since the qualifiers will be
      parsed following a call to this function, but before {@link #getQualifiers()}
      returns. Also setting the qualifiers to "{}" will result in the get function
      returning null.
      @param quals The full qualifier string.
   */
  public void setQualifiers (String quals) {
    qualifiers = quals;
    qualTable  = null;
  }

  /** Set a qualifier by name and value.
   *  @param key   qualifier name
   *  @param value qualifier value
   */
  public void setQualifier (String key, Object value) {
    // if (qualTable==null) qualTable = new Table(qualifiers); // commented out as part of BUG 2497 workoff
    // qualifiers = null;                                      // moved to end as part of BUG 2497 workoff
    // qualTable.put(key,value);                               // switched to below line as part of BUG 2497 workoff
    getQualifierTable().put(key, value);
    qualifiers = null;                                         // qualifiers string is now invalid
  }

  /** Get a qualifier by name
   *  @return a qualifier by name.
   *  @param key name of qualifier
   */
  public Object getQualifier (String key) {
    return getQualifierTable().get(key);
  }

  /** Get a qualifier by name with default value if not found.
      @param key name of qualifier
      @param defValue default value
      @return the selected qualifier
      @since NeXtMidas 3.1.0
   */
  public Object getQualifier (String key, Object defValue) {
    return getQualifierTable().get(key,defValue);
  }

  /** Add the specified default qualifier (key/value) in this FileName.
      Any existing entries in the qualifiers table (e.g. user specified or previous added) will NOT be overridden.
      @param key   The qualifier's key.
      @param value The qualifier's (new) value.
      @return true if key/value is added to the file's qualifiers table, otherwise false
                   (e.g. there is an existing user specified qualifier for that key).
      @see Table#addIfNotPresent(String,Object)
      @since NeXtMidas 3.1.0
    */
  public boolean setDefaultQualifier (String key, Object value) {
    return getQualifierTable().addIfNotPresent(key, value);
  }

  /** Add following Map of key=value,...,keyN=valueN to the default qualifiers
      in this FileName. Any existing entries in the qualifiers table (e.g. user
      specified or previous added) will NOT be overridden.
      @param defQualsMap default qualifiers Map (e.g. Table) to add to file
      @return true if any key/value from defQualsMap is added to the file's qualifiers table.
      @see #setDefaultQualifier(String, Object)
      @see Table#addIfNotPresent(Map)
      @since NeXtMidas 3.1.0
   */
  public boolean setDefaultQualifiers (Map<String,Object> defQualsMap) {
    return getQualifierTable().addIfNotPresent(defQualsMap);
  }

  /** Does the file name have any trimmers?
   *  @return Does the file name have any trimmers? */
  public boolean hasTrimmers () { return (trimmers!=null); }

  /** Get the trimmers of the current filename
   *  @return the trimmers of the current filename.  */
  public String getTrimmers () { return trimmers; }

  /** Add trimmers or qualifiers by string
   *  @param filename file name with trimmers and qualifiers
   */
  @InternalUseOnly // Since 3.3.1 - should only be used by BaseFile
  public void append (String filename) {
    int i = getQualifiersIndex(filename); // file qualifiers
    if (i>=0) {
      Table newQuals = Convert.o2t(filename.substring(i));
      getQualifierTable().merge(newQuals);
      filename = filename.substring(0,i);
    }
    i = getTrimmersIndex(filename); // file trimmers
    if (i>=0) {
      trimmers = filename.substring(i);
//    filename = filename.substring(0,i); // 2011-12-09 NTN: commented out, since not used afterwards
    }
  }

  @InternalUseOnly
  private String toDebugString () {
    return "FileName"
         + "\n  Path          : "+getPath()
         + "\n  Root          : "+getRoot()
         + "\n  Ext           : "+getExt()
         + "\n  Full          : "+getFullName()
         + "\n  Trim          : "+getTrimmers()
         + "\n  Tag           : "+getTag()
         + "\n  Qual (String) : "+getQualifiers()
         + "\n  Qual (Table)  : "+getQualifierTable();
  }

  /** Dump the class members to {@link System#out} for debugging */
  @InternalUseOnly // since 3.3.1
  public void dump () {
    System.out.println(toDebugString());
  }

  /** Return the full path string */
  @Override
  public String toString () {
    String tmp = getFullName();
    if (trimmers!=null) tmp+=trimmers;
    if (qualifiers!=null) tmp+=qualifiers;
    return tmp;
  }

  /** Converts the file name to a local file name using {@link FileUtil#toLocalFileName(URL)}
      @param dir I/O direction. See "Direction Values" at the top of {@link IOResource} for details
      @return a local file name or null if the file is a remote resource
      @throws MalformedURLException if a URL cannot be formed
      @since NeXtMidas 3.1.0
   */
  public String toLocalFileName (int dir) throws MalformedURLException {
    return FileUtil.toLocalFileName(toURL(dir));
  }

  /** Converts the file name to a local file name using {@link FileUtil#toLocalFileName(URL)}
      @return A local file name or null if the file is a remote resource
      @throws MalformedURLException if a URL cannot be formed
      @since NeXtMidas 3.1.0
   */
  public String toLocalFileName () throws MalformedURLException {
    return FileUtil.toLocalFileName(toURL());
  }

  /** Converts an object into a file name. If the object is a {@link FileName}, the given object is
      just returned. If the object is not a {@link FileName} it is converted to a string and then a
      new {@link FileName} is created.
      @param obj An object.
      @return A file name.
      @since NeXtMidas 2.3.1
   */
  public static FileName toFileName (Object obj) {
    FileName fn;

    if (obj instanceof FileName) {
      fn = (FileName)obj;
    } else {
      fn = new FileName(Convert.o2s(obj));
    }
    return fn;
  }

  /** Converts an object into a URL. If the object is a {@link URL}, the given object is just
      returned; if the object is a {@link URI} it is converted to a {@link URL}, otherwise the
      the NeXtMidas file naming conventions are assumed (AUXs, qualifiers, etc) and this parses
      the name based on them. <br>
      <br>
      Unlike simply calling <code>new URL(..)</code>, this knows how to resolve Midas'isms like the
      "nxm." syntax and knows to omit any file qualifiers or trimmers.
      @param ref The reference to use (usually an instance of {@link Midas} or {@link Command}).
      @param obj The filename to parse.
      @param dir I/O direction. See "Direction Values" at the top of {@link IOResource} for details.
      @return A file name.
      @throws MalformedURLException if a URL can not be formed.
      @since NeXtMidas 2.7.0
   */
  public static URL toURL (Object ref, Object obj, int dir) throws MalformedURLException {
    if (obj instanceof URL) return (URL)obj;
    if (obj instanceof URI) return ((URI)obj).toURL();

    BaseFile bf  = new BaseFile(Convert.ref2Midas(ref), obj);
    String   url = bf.findResource(dir).getURL();

    URL newURL = null;
    try {
      newURL = (new URI(url)).toURL();
    }
    catch (URISyntaxException e) {
      throw new MalformedURLException("Forming URL via URI failed:"+e.getMessage()+" "+e.getStackTrace());
    }
    return newURL;
  }

  /** Converts the file name to a URL using <code>toURL(null,this,dir)</code>.
      @param dir I/O direction. See "Direction Values" at the top of {@link IOResource} for details.
      @return A URL.
      @throws MalformedURLException if a URL can not be formed.
      @since NeXtMidas 2.7.0
   */
  public URL toURL (int dir) throws MalformedURLException {
    return toURL(null, this, dir);
  }

  /** Converts the file name to a URL using {@link #toURL(int)} with a dir of 0
      @return a URL
      @throws MalformedURLException if a URL cannot be formed
      @since NeXtMidas 3.1.0
   */
  public URL toURL () throws MalformedURLException {
    return toURL(0);
  }

  /** Return a new filename given a name relative to an original context. This
      method assumes that the context is a fully qualified path name to a
      file of some sort (not just a directory). The operation of this method
      is easier to illustrate by example than by algorithm.   In all of these
      examples, the context is 'nxm/sys/hlp/concepts.hlp' and the token is '/'.
      <br>
      All files within NeXtMidas have extensions.  If the name parameter does
      not have an extension on it then the returned name will have the same
      extension as this object.
      <pre>
        relativeTo("plot.hlp")          --&amp;gt "nxm/sys/hlp/plot.hlp"
        relativeTo("exp/fft.exp")       --&amp;gt "nxm/sys/hlp/exp/fft.exp"
        relativeTo("jay/exp/foo.exp")   --&amp;gt "nxm/sys/hlp/jay/exp/foo.exp"
        relativeTo("include_data")      --&amp;gt "nxm/sys/hlp/include_data.hlp" (no extension is NOT RECOMMENDED!)
        relativeTo("file.ext area")     --&amp;gt "$NMROOT/nxm/sys/area/file.ext"
        relativeTo("file.ext area opt") --&amp;gt "$NMROOT/nxm/&lt;opt&gt;/area/file.ext" (option tree under $NMROOT only!)
      </pre>
      @param name The name of the file relative to this one.
      @return The full name of the file relative to this one.
      @throws MalformedURLException if there are problems with the protocol
  */
  public FileName relativeTo (final String name) throws MalformedURLException {
    String context = getFullName();

    // If the name has spaces then assume it is specifying areas, option trees (under $NMROOT),
    // and files.  If there are no spaces then assume it's a relative filename.
    String[] fields = StringUtil.cleanSplit(name, " ");
    String nmroot = Shell.getNmRoot(); // 2010-01-08 NTN: changed to work under Java Web Start for Bug 1463
    if ( nmroot == null ) nmroot = "";

    URL newUrl;
    String retval;

    switch ( fields.length ) {
      case ( 2 ): { // <filename> <area> of SYS option tree
        String _path = getOptionPath(nmroot, "sys", fields[1]);
        newUrl = FileUtil.toURL(_path+fields[0]);
        retval = newUrl.toString();  // get full URL to handle jar files (since 3.7.3)
        break;
      }
      case ( 3 ): { // <filename> <area> <optionTree under $NMROOT>
        String _path = getOptionPath(nmroot, fields[2], fields[1]);
        // Since 3.7.1 fix so works under Windows (force creation of URL with back slashes)
        newUrl = FileUtil.toURL( _path+fields[0]);
        retval = newUrl.toString();  // get full URL to handle jar files (since 3.7.3)
        break;
      }
      default: { // <filename>
        // Since 3.7.1 fix so works under Windows (force creation of URL with forward slashes)
        newUrl = FileUtil.toURL(context,name);
        retval = newUrl.getFile();
        break;
      }
    }

    // Using the newly constructed URL, determine if we need to add an extension.
    // NTN: fix for Bug 1463 e.g. when retval = "jar:http://server/nxm/jars/nxm-sys.jar!/nxm/sys/test/include_data"
    FileName retfn = new FileName(retval, true); // true to keep case
    String retext = retfn.getExt();
    if (retext == null || retext.isEmpty()) {
      retfn.setExt(getExt());
    }
    return retfn;
  }

  /**
   * Convert from a DOS path to a path that can be used to create the URL
   * <literal>
   * C:\path\to\file becomes file:/C:path/to/file
   * partial\path becomes partial/path
   * </literal>
   * file becomes file
   * @param path DOS path
   * @return path in format needed to create URL
   * @since NeXtMidas 3.7.1 for NXM-2942
   */
  private static String convertDOSpath ( String path )
  {
    if (path==null) return null;
    // switch file separators
    String newPath = separatorsToSystem(path);
    int indexFirstColon = newPath.indexOf(":");
    // Only needs modifications if is path of form <drive_letter>:path/to/file
    if (newPath.contains("/") && indexFirstColon == 1 ) {
        newPath = "file:/" + newPath;
    }
    return newPath;
  }

  /**
   * Static method for converting a URL string to the correct file path separator
   * @param path path with system dependent separators
   * @return path with correct separators
   * @since NeXtMidas 3.7.1 for NXM-2942
   */
  private static String separatorsToSystem(String path){
    if (path==null) return null;
    if (File.separatorChar == BSLASH) return path.replace(BSLASH, SLASH);
    else return path;
  }

  /** Internal use version that throws a MidasException. */
  private FileName _relativeTo (String name) {
    try {
      return relativeTo(name);
    }
    catch (MalformedURLException e) {
      throw new MidasException("Error getting relative name", e);
    }
  }

  /** Creates a clone of this instance. This function is intended for internal
      use only and will likely be renamed in a future release since it isn't a
      true ".clone()" of the object and has special handling for qualifiers.
      @return a clone of this instance
   */
  @InternalUseOnly // <-- since NeXtMidas 3.3.1
  public FileName cloneOf () {
    try {
      FileName fn = (FileName)this.clone();
      // The next two lines force a "deep copy" of the qualifier table
      fn.qualifiers = getQualifiers();
      fn.qualTable  = null;
      return fn;
    }
    catch (CloneNotSupportedException e) {
      throw new AssertionError("Clone of FileName failed");
    }
  }

  /** @since 3.3.4 */
  @Override
  public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + ((ext == null) ? 0 : ext.hashCode());
    result = prime * result + ((path == null) ? 0 : path.hashCode());
    result = prime * result + ((qualifiers == null) ? 0 : qualifiers.hashCode());
    result = prime * result + ((root == null) ? 0 : root.hashCode());
    result = prime * result + ((tag == null) ? 0 : tag.hashCode());
    result = prime * result + ((trimmers == null) ? 0 : trimmers.hashCode());
    return result;
  }

  /** @since 3.3.4 */
  @Override
  public boolean equals(Object obj) {
    if (this == obj) {
      return true;
    }
    if (obj == null) {
      return false;
    }
    if (getClass() != obj.getClass()) {
      return false;
    }
    FileName other = (FileName) obj;
    if (ext == null) {
      if (other.ext != null) {
        return false;
      }
    }
    else if (!ext.equals(other.ext)) {
      return false;
    }
    if (path == null) {
      if (other.path != null) {
        return false;
      }
    }
    else if (!path.equals(other.path)) {
      return false;
    }
    if (qualifiers == null) {
      if (other.qualifiers != null) {
        return false;
      }
    }
    else if (!qualifiers.equals(other.qualifiers)) {
      return false;
    }
    if (root == null) {
      if (other.root != null) {
        return false;
      }
    }
    else if (!root.equals(other.root)) {
      return false;
    }
    if (tag == null) {
      if (other.tag != null) {
        return false;
      }
    }
    else if (!tag.equals(other.tag)) {
      return false;
    }
    if (trimmers == null) {
      if (other.trimmers != null) {
        return false;
      }
    }
    else if (!trimmers.equals(other.trimmers)) {
      return false;
    }
    return true;
  }

//********************************************************************** //
//PUBLIC STATIC METHODS
//********************************************************************** //


  /** Gets the default FileName case.
      @return the default filename case
      @since NeXtMidas 3.3.1
   */
  public static FNCase getDefaultFNCase () {
    return defaultFNCase;
  }

  /** Sets the default FileName case. Calling this with null sets the default to
      match the built-in default for this NeXtMidas version.
      @param fnCase default FileName case
      @since NeXtMidas 3.3.1
   */
  public static void setDefaultFNCase (FNCase fnCase) {
    defaultFNCase = (fnCase != null)? fnCase : FNCase.AutoCase;
  }

  /** Sets the default FileName case. Calling this with null sets the default to
      match the built-in default for this NeXtMidas version.
      @param fnCase default FileName case
      @since NeXtMidas 3.3.1
   */
  public static void setDefaultFNCase (String fnCase) {
    String val = Convert.o2s(fnCase);
    if (StringUtil.isNull(val)) {
      setDefaultFNCase((FNCase)null);
    }
    else {
      setDefaultFNCase(Util.valueOf(FileName.FNCase.class, val));
    }
  }

  /** Creates a file name for a file within an AUX or option tree. This can be
      used in one of two ways:
      <pre>
        getFileName(opt,  area, name, ext, fnCase)   - File in option tree
        getFileName("AUX", aux, name, ext, fnCase)   - File in AUX
      </pre>
      This function is the underlying function used by the <code>FNAME</code> command.
      @param opt    The option tree.
      @param area   Area of the option tree.
      @param name   Name of the file.
      @param ext    File name extension.
      @param fnCase The file name case option (null = use default from
                    {@link #getDefaultFNCase()}). <i>Note that "opt" and "area"
                    are case-insensitive regardless of the value of "fnCase".</i>
      @return The file name.
      @since NeXtMidas 3.3.1
   */
  public static FileName getFileName (String opt, String area, String name,
                                      String ext, FNCase fnCase) {
    String path = (opt.equalsIgnoreCase("AUX"))? Shell.getAuxPath(area)
                                               : Shell.getOptPath(opt,area);

    return new FileName(path, name, ext, fnCase);
  }

  /** Add required terminating directory separator if not already present
      The end terminator is gleaned from the input path
      @param inpath Input path
      @return The terminated path.
   */
  public static String terminatePath (String inpath) {
    return FileUtil.terminatePath(inpath);
  }

  /** Return the index to the [start, end] of the qualifier information in the given
      URL. If there are no qualifiers on the URL or the URL is null then this
      method will return -1.  Qualifiers are indicated on the url by the
      presence of a left curly brace ({).
      @param url The url to search for the qualifiers.
      @return The position of the [start, end] of the qualifier information in the
              url or -1 if there is no qualifier information.
   */
  private static int[] getQualIndex (String url) {
    if (url == null) return null;

    // url = trim(url); // ORIG 2.7.3 and prior
    url = StringUtil.rtrim(url); // remove trailing whitespace (TODO: JC4, why not trim leading whitespaces?)
    if (url.length() == 0) return null;
    if (!url.endsWith("}") && !url.endsWith(")")) {
      return null;
    }

    int end   = url.length();
    int index = -1;

    while (url.length() > 0) {
      int i = -1;

      if (url.endsWith("}")) { // standard NeXtMidas qualifier table
        i = StringUtil.getOpeningFensePos(url, "{}", url.length()-1);
      }
      else if (url.endsWith(")")) { // old-style single-enrty X-Midas qualifier
        i = url.lastIndexOf('('); // no nesting with this style
        if ((i > 0) && url.substring(i).indexOf('=') < 0) i = -1;
      }

      if (i < 0) break;

      index = i;
      url = url.substring(0,i);
    }
    return new int[] { index, end };
  }

  /** Return the index to the start of the qualifier information in the given
      URL. If there are no qualifiers on the URL or the URL is null then this
      method will return -1.  Qualifiers are indicated on the url by the
      presence of a left curly brace ({).
      @param url The url to search for the qualifiers.
      @return The position of the start of the qualifier information in the
              url or -1 if there is no qualifier information.
   */
  public static int getQualifiersIndex (String url) {
    int[] index = getQualIndex(url);
    return (index == null)? -1 : index[0];
  }

  /** Return the string of the qualifier data that is contained in the url.
      If there is no qualifier string present, the method will return null.
      For example, if url is "http://localhost/foo.tmp{exist=true}", then this
      method will return "{exist=true}".
      @param url The url to check for qualifiers
      @return The qualifier string (including ending and closing qualifiers)
              or null if no qualifiers exist.
   */
  public static String getQualifierString (String url) {
    int[] index = getQualIndex(url);
    return (index == null)? null : url.substring(index[0], index[1]);
  }

  /** Get the trimmers start index
      @param url url with trimmers
      @return index of trimmers
  */
  public static int getTrimmersIndex (String url) {
    // url = trim(url); // ORIG 2.7.3 and prior
    url = StringUtil.rtrim(url); // remove trailing whitespace (TODO: JC4, why not trim leading whitespaces?) JTM probably because we are just concerned with the trimmers

    int index = getQualifiersIndex(url);
    if (index > 0) url = url.substring(0,index); // ignore any qualifiers at the end

    if (!url.endsWith(")")) return -1;

    int i = url.lastIndexOf('('); // no nesting with trimmers
    if ((i > 0) && url.substring(i).indexOf(':') < 0) i = -1;
    return i;
  }

  /** Creates an option tree path in a system independent manner.  Uses the rootpath
      to determine the file system type.  The root path must have a delimiter.
      Note: opt and area will be converted to lower case to match NeXtMidas naming
      convention when forming path on OS file system.
      @param rootpath the path to start from (cannot be null!)
      @param opt      the name of the option tree
      @param area     the area in the option tree (to append to return value if NOT null)
      @return option tree path
      @throws IllegalArgumentException if rootpath is null (Since NeXtMidas 3.1.2)
   */
  public static String getOptionPath (String rootpath, String opt, String area) {
    if (rootpath == null) throw new IllegalArgumentException("rootpath cannot be null!");
    String path = addPathDir(rootpath,"nxm");
    if (opt  != null) path = addPathDir(path,opt.toLowerCase());
    if (area != null) path = addPathDir(path,area.toLowerCase());
    return path;
  }

  /** Appends a directory to an existing path. The path (directory) separator is
      based on input path string and will always be included in return value.
      This will handle case where path does not have path terminator by adding it
      before appending the specified directory.
      @param path existing path (cannot be null or empty)
      @param dir  directory to append to path (if null or empty, then path is returned)
      @return new path string with a terminating path separator based on input path.
      @throws IllegalArgumentException if path is null AND dir is not null or empty
      @throws IllegalArgumentException if path is blank/empty (i.e. length of zero)
   */
  public static String addPathDir (String path, String dir) {
// 2010-01-08 NTN: TODO: Jeff, what should be returned when path is null or empty? (vs the above exceptions?)
    if (dir==null || dir.length()==0) return path;
    if (path==null) throw new IllegalArgumentException("path cannot be null!");
    int lp = path.length();
    if (lp==0) throw new IllegalArgumentException("path cannot be blank!");
    char term = path.charAt(lp-1);
         if (term==SLASH) path = path + dir + SLASH;
    else if (term==RBRACE) path = path.substring(0,lp-1) + PERIOD + dir + RBRACE; // TODO: Jeff why is a period inserted for this special case?
    else if (term==BSLASH) path = path + dir + BSLASH;
    else if (path.indexOf(BSLASH)>=0) path = path + BSLASH + dir + BSLASH;
    else path = path + SLASH + dir + SLASH;
    return path;
  }

  /** appends a filename root to an existing path
   *  @param path path that needs root added
   *  @param root root to add to path
   *  @return full filename with root appended
   */
  public static String addPathRoot (String path, String root) {
    if (root==null || root.length()==0) return path;
    int lp = path.length();
    if (lp==0) return root;
    char term = path.charAt(lp-1);
         if (term==SLASH) path += root;
    else if (term==RBRACE) path += root;
    else if (term==BSLASH) path += root;
    else if (path.indexOf(BSLASH)>=0) path += BSLASH + root;
    else path += SLASH + root;
    return path;
  }

  /** appends a filename extension to an existing path
   *  @param path existing path
   *  @param ext  extension
   *  @return path with file extension appended
   */
  public static String addPathExt (String path, String ext) {
    if (ext==null || ext.length()==0) return path;
    return path + PERIOD + ext;
  }

  /** appends a filename root and extension to an existing path
   *  @param path existing path
   *  @param root filename root
   *  @param ext  extension
   *  @return full path including filename and extension
   */
  public static String addPathRootExt (String path, String root, String ext) {
    return addPathExt(addPathRoot(path,root), ext);
  }
}
