Source: xxe/res/URIUtil.js

const CSRI_SEPARATOR_NO_AUTHORITY = ")@no-authority/";
const CSRI_SEPARATOR_OPAQUE_URI = ")@opaque-uri/";
const CSRI_SEPARATORS = [
    CSRI_SEPARATOR_NO_AUTHORITY,
    CSRI_SEPARATOR_OPAQUE_URI,
    ")@",
    ")"
];

/**
 * The file path separator on Windows , that is, <code>'\'</code>.
 * @type string
 */
const WINDOWS_FILE_PATH_SEPARATOR = '\\';

/**
 * The platform specific file path separator, that is, <code>'\'</code>
 * on Windows and <code>'/'</code> on the other platforms.
 * @type string
 */
const FILE_PATH_SEPARATOR =
      PLATFORM_IS_WINDOWS? WINDOWS_FILE_PATH_SEPARATOR : '/';

/**
 * Some helper functions (static methods) related to URIs and to file paths.
 */
/*TEST|export|TEST*/ class URIUtil {
    // -----------------------------------------------------------------------
    // "csri:" server specific URL to normal client URI
    // -----------------------------------------------------------------------
    
    /**
     * Converts specified URI to an actual URI if it's a "csri:" URL 
     * <small>(Client Side Resource Identifier, an URL form common to 
     * {@link XMLEditor} and XXE server of an URI managed by 
     * client-code)</small>; returns specified URI as is otherwise.
     * <p>"csri:" URL examples:
     * <pre>csri://(file)@no-authority/tmp/doc.xml
     *csri://(file)@myserver/share/docs/doc.xml
     *csri://(urn)@opaque-uri/ietf:rfc:2648
     *csri://(https)@www.xmlmind.com:80/xmleditor/index.html
     *csri://(ftp)foo:bar@xmlmind.com/xmleditor/index.html</pre>
     *
     * @param {string} url - an URL, possibly a "csri:" one.
     * @return {string} corresponding URI.
     */
    static csriURLToURI(url) {
        if (url === null || !url.startsWith("csri://(")) {
            return url;
        }

        let uri = url;
        
        for (let separ of CSRI_SEPARATORS) {
            let pos = uri.indexOf(separ, 8);
            if (pos > 8) {
                let scheme = uri.substring(8, pos).trim();
                if (scheme.length > 0) {
                    let tail;
                    if (CSRI_SEPARATOR_NO_AUTHORITY === separ) {
                        tail = "///" + uri.substring(pos + separ.length);
                    } else if (CSRI_SEPARATOR_OPAQUE_URI === separ) {
                        tail = uri.substring(pos + separ.length);
                    } else {
                        tail = "//" + uri.substring(pos + separ.length);
                    }

                    uri = scheme + ":" + tail;
                }

                // Done.
                break;
            }
        }

        return uri;
    }
    
    // -----------------------------------------------------------------------
    // URI utilities
    // -----------------------------------------------------------------------
    
    /**
     * Returns the basename, if any, of specified URI.
     *
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @return {string} the basename of specified URI <small>(decoded using 
     * <code>decodeURIComponent</code>)</small> if any; 
     * <code>null</code> otherwise.
     */
    static uriBasename(uri) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        if (!path) {
            return null;
        }

        let basename = URIUtil.pathBasename(path, '/');
        return !basename? null : decodeURIComponent(basename);
    }

    /**
     * Changes the basename of specified URI.
     *
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @param {string} basename - the new basename <small>(to be encoded using
     * <code>encodeURIComponent</code>)</small>.
     * @return {string} modified URI; <code>null</code> if specified 
     * URI has no path.
     */
    static uriSetBasename(uri, basename) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        if (!path) {
            return null;
        }

        basename = encodeURIComponent(basename);
        let parent = URIUtil.pathParent(path, '/', /*trailingSepar*/ true);
        if (!parent) {
            // Common case: uri consists in just a basename (e.g. "bin.dat").
            path = basename;
        } else {
            path = parent + basename;
        }
        
        return URIUtil.joinURI(scheme, authority, path, query, fragment);
    }

    /**
     * Splits specified URI into its components: 
     * scheme, authority, path, query, fragment.
     * <p>Note that returned URI components are <em>NOT</em> decoded using 
     * <code>decodeURIComponent</code>.
     *
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @return {array} an array containing scheme, authority, path, query, 
     * fragment, each of this component possibly being <code>null</code> or 
     * just the empty string.
     */
    static splitURI(uri) {
        if (!uri) {
            return [ null, null, null, null, null ];
        }
        
        // Cannot simply use the URL API.
        // The URL API consider any protocol other than "http", "https", "ftp"
        // as being opaque.
        // For example, (new URL(uri)).pathname for "foo://bar.com/gee/wiz.dat"
        // would be "//bar.com/gee/wiz.dat".

        let scheme = null;
        let authority = null;
        let path = null;
        let query = null;
        let fragment = null;

        // Authority?
        let pos = uri.indexOf("://");
        if (pos < 0) {
            // No authority. Example: "file:/tmp/server.log".
            
            pos = uri.indexOf(":");
            if (pos < 0) {
                // No scheme. Example: "../foo/bar.txt".
                path = uri;
            } else {
                // Example: "urn:isbn:8088451".
                scheme = uri.substring(0, pos);
                path = uri.substring(pos+1); 
            }
        } else {
            // Example: "ftp://john@acme.com/foo/bar.txt".
            scheme = uri.substring(0, uri.indexOf(":"));

            pos += 3; // Skip "://".
            let slash = uri.indexOf("/", pos);
            if (slash < 0) {
                authority = uri.substring(pos);
                // Example: "http://acme.com". No path, but "" and not null
                // for consistency with the other cases.
                path = "";
            } else {
                authority = uri.substring(pos, slash);
                // Path starting with "/".
                path = uri.substring(slash);
            }
        }

        // Fragment?
        if (path !== null) {
            pos = path.lastIndexOf("#");
            if (pos >= 0) {
                fragment = path.substring(pos+1);
                path = path.substring(0, pos);
            }
        }
        
        // Query?
        if (path !== null) {
            pos = path.lastIndexOf("?");
            if (pos >= 0) {
                query = path.substring(pos+1);
                path = path.substring(0, pos);
            }
        }
        
        return [ scheme, authority, path, query, fragment ];
    }

    /**
     * Returns the file extension, if any, of specified URI.
     *
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @return {string} the file extension of specified URI if any; 
     * <code>null</code> otherwise.
     */
    static uriExtension(uri) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        if (!path) {
            return null;
        }

        let ext = URIUtil.pathExtension(path, '/');
        return !ext? null : decodeURIComponent(ext);
    }

    /**
     * Returns the parent, if any, of specified URI.
     *
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @param {string} [trailingSepar=true] trailingSepar - if 
     * <code>true</code>,  returned URI has a path which ends with 
     * a trailing <code>'/'</code> character.
     * @return {string} the parent of specified URI if any; 
     * <code>null</code> otherwise.
     */
    static uriParent(uri, trailingSepar=true) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        if (!path) {
            return null;
        }

        let parent = URIUtil.pathParent(path, '/', trailingSepar);
        if (!parent) {
            return null;
        }
        
        return URIUtil.joinURI(scheme, authority, parent);
    }
    
    /**
     * Join specified URI components and return resulting URI.
     * <p>Specified URI components are joined as is, that is, 
     * without first encoding them using <code>encodeURIComponent</code>.
     */
    static joinURI(scheme, authority, path, query=null, fragment=null) {
        let uri = "";
        if (scheme) {
            uri += scheme;
            uri += ':';
        }
        if (authority) {
            uri += "//";
            uri += authority;
        }
        if (path) {
            uri += path;
        }
        if (query) {
            uri += '?';
            uri += query;
        }
        if (fragment) {
            uri += '#';
            uri += fragment;
        }
        
        return uri;
    }

    /**
     * Tests whether specified URI is an absolute hierarchical URI.
     * 
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @return {boolean} <code>true</code> if it's an absolute 
     * hierarchical URI; <code>false</code> otherwise.
     */
    static isAbsoluteURI(uri) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        return (scheme && path && path.startsWith('/'))? true : false;
    }
    
    /**
     * Returns specified URI after making it relative to the other URI
     * (when this is possible).
     * 
     * @param {string} uri - an absolute, <em>valid</em> URI.
     * @param {string} baseURI - another absolute, <em>valid</em> URI
     * which is used as a base URI.
     * @return {string} the relative URI when possible; 
     * specified uri as is otherwise (for example, when specified URIs
     * don't have the same scheme or authority).
     */
    static relativizeURI(uri, baseURI) {
        // uri and baseURI must be absolute URIs having the same scheme and
        // authority (case-insensitive).
        
        let [scheme1, authority1, path1, query1, fragment1] =
            URIUtil.splitURI(uri);
        if (scheme1) {
            scheme1 = scheme1.toLowerCase();
        }
        let auth1 = authority1;
        if (!auth1) {
            auth1 = null;
        } else {
            auth1 = auth1.toLowerCase();
            if (scheme1 === "file" && auth1 === "localhost") {
                auth1 = null;
            }
        }
        
        let [scheme2, authority2, path2, query2, fragment2] =
            URIUtil.splitURI(baseURI);
        if (scheme2) {
            scheme2 = scheme2.toLowerCase();
        }
        let auth2 = authority2;
        if (!auth2) {
            auth2 = null;
        } else {
            auth2 = auth2.toLowerCase();
            if (scheme2 === "file" && auth2 === "localhost") {
                auth2 = null;
            }
        }
        
        if (!scheme1 || !path1 || !path1.startsWith('/') ||
            !scheme2 || !path2 || !path2.startsWith('/') ||
            scheme1 !== scheme2 || auth1 !== auth2) {
            return uri;
        }

        // ---
        
        if (PLATFORM_IS_WINDOWS && scheme1 === "file") {
            // On Windows, "file:" URLs may have different volumes (e.g.
            // file:/C:/foo and file:/D:/bar). When this is the case, do not
            // attempt to compute a relative path.

            let drive1 = null;
            let match = path1.match(/^\/([a-zA-Z]:)\//);
            if (match !== null) {
                drive1 = match[1].toUpperCase(); // Not case-sensitive.
                                                 // Normalize to upper-case.
            }
            
            let drive2 = null;
            match = path2.match(/^\/([a-zA-Z]:)\//);
            if (match !== null) {
                drive2 = match[1].toUpperCase();
            }

            if (drive1 !== drive2) {
                return uri;
            }
        }

        let relPath = URIUtil.relativizePath(path1, path2, '/');
        return URIUtil.joinURI(/*scheme*/ null, /*authority*/ null,
                               relPath, query1, fragment1);
    }
    
    /**
     * Returns specified relative URI after resolving it against the other URI.
     * 
     * @param {string} uri - a relative or absolute, <em>valid</em> URI.
     * @param {string} baseURI - an absolute, <em>valid</em> URI
     * which is used as a base URI.
     * @return {string} the resolved URI when possible; 
     * specified uri as is otherwise (for example, when specified URI 
     * is already absolute).
     */
    static resolveURI(uri, baseURI) {
        // baseURI must be an absolute URI.
        let [scheme2, authority2, path2, query2, fragment2] =
            URIUtil.splitURI(baseURI);
        if (!scheme2 || !path2 || !path2.startsWith('/')) {
            return uri;
        }
        
        let [scheme1, authority1, path1, query1, fragment1] =
            URIUtil.splitURI(uri);
        if (scheme1 && path1 && path1.startsWith('/')) {
            // Already absolute: nothing to do.
            return uri;
        }

        let absPath = !path1? "/" : URIUtil.resolvePath(path1, path2, '/');
        return URIUtil.joinURI(scheme2, authority2,
                               absPath, query1, fragment1);
    }

    /**
     * Returns specified absolute URI after normalizing its authority 
     * if it's a "file:" URI. Otherwise returns specified URI as is.
     */
    static normalizeFileURI(uri) {
        let uri2;
        if (uri && (uri2 = uri.toLowerCase()).startsWith("file:/")) {
            let matches = (new RegExp("^file://([^/]*)/")).exec(uri2);
            if (matches === null) {
                // file:/PATH.
                uri = "file:///" + uri.substring(6);
            } else {
                if (matches[1] === "localhost") {
                    // file://localhost/PATH.
                    uri = "file:///" + uri.substring(17);
                }
                // Otherwise, file:///PATH or file://AUTH/PATH. Leave it as is.
            }
        }
        
        return uri;
    }
    
    // -----------------------------------------------------------------------
    // URI to platform specific file path and the other way round
    // -----------------------------------------------------------------------
    
    /**
     * Returns the file path corresponding to specified absolute "file:" URI.
     *
     * @param {string} uri - an absolute, <em>valid</em>, "file:" URI.
     * @param {string} [pathSepar=FILE_PATH_SEPARATOR] pathSepar - 
     * the character used to separate file path segments 
     * (that is, '\' on Windows).
     * @return {string} corresponding absolute file path when possible, 
     * <code>null</code> otherwise.
     */
    static uriToFilePath(uri, pathSepar=FILE_PATH_SEPARATOR) {
        let [scheme, authority, path, query, fragment] = URIUtil.splitURI(uri);
        if (!scheme || scheme !== "file") {
            return null;
        }
        if (!path) {
            path = "/";
        }
        
        // Ignore authority, query and fragment.
        let segments = path.split('/');
        for (let i = segments.length-1; i >= 0; --i) {
            segments[i] = decodeURIComponent(segments[i]);
        }
        let file = segments.join(pathSepar);
        
        if (pathSepar === WINDOWS_FILE_PATH_SEPARATOR) {
            if (authority && authority !== "localhost") {
                // Special processing of "file://server/path".
                file = "\\\\" + authority + file;
            } else {
                // "\C:" becomes "C:\".
                // "\C:\temp\foo" becomes "C:\temp\foo".
                
                if (file.match(/^\\[a-zA-Z]:/)) {
                    if (file.length === 3) {
                        file = file.substring(1) + "\\";
                    } else if (file.charAt(3) === "\\") {
                        file = file.substring(1);
                    }
                }
            }
        }

        return URIUtil.normalizePath(file, pathSepar);
    }

    /**
     * Convert specified path to a "file:" URI.
     * 
     * @param {string} path - relative or absolute path.
     * @param {string} [pathSepar=FILE_PATH_SEPARATOR] pathSepar - 
     * the character used to separate file path segments 
     * (that is, '\' on Windows).
     * @return {string} corresponding relative or absolute "file:" URI.
     * Note that if <code>path</code> is relative, this URI will have no
     * "file:" scheme (e.g. returns "tmp/my%20doc.xml" for "tmp\\my doc.xml");
     */
    static pathToFileURI(path, pathSepar=FILE_PATH_SEPARATOR) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);

        let uri;
        if (URIUtil.testIsAbsolute(drive, checkedPath, pathSepar)) {
            uri = "file://";
            if (drive !== null) {
                if (drive.match(/^[a-zA-Z]:/)) {
                    uri += "/" + drive;
                } else {
                    // UNC path like "\\serv\share\doc.xml".
                    // Discard leading "\\".
                    uri += drive.substring(2); 
                }
                // uri is something like "file:///C:" or "file://my-server".
            }
        } else {
            // Relative path. Resulting uri will have no "file:" scheme.
            uri = "";
        }

        let parts = checkedPath.split(pathSepar).map((part) => {
            if (part.length > 0) {
                part = encodeURIComponent(part);
            }
            return part;
        });

        uri += parts.join('/');
        
        return uri;
    }

    static checkPath(path, pathSepar) {
        let drive = null;
        
        if (pathSepar === WINDOWS_FILE_PATH_SEPARATOR) {
            // Example: \\ComputerName\SharedFolder\Resource
            let match = path.match(/^(\\\\[^\\]+)\\/);
            if (match !== null) {
                drive = match[1].toUpperCase(); // Not case-sensitive.
                                                // Normalize to upper-case.
                path = path.substring(drive.length);
            } else {
                // Example: C:\Folder\File
                // Drive relative paths like D:File not supported.
                let match = path.match(/^([a-zA-Z]:)\\/);
                if (match !== null) {
                    drive = match[1].toUpperCase(); // Not case-sensitive.
                                                    // Normalize to upper-case.
                    path = path.substring(drive.length);
                }
            }
        }
        // Otherwise, we have an absolute or relative path.

        path = URIUtil.doNormalizePath(path, pathSepar);

        return [drive, path];
    }
    
    static doNormalizePath(path, pathSepar) {
        const originalPath = path;
        
        const prependSlash = path.startsWith(pathSepar);
        const appendSlash = path.endsWith(pathSepar);
        if (prependSlash || appendSlash) {
            let start = 0;
            let end = path.length-1;

            while (start <= end && path.charAt(start) === pathSepar) {
                ++start;
            }
            while (end >= start && path.charAt(end) === pathSepar) {
                --end;
            }

            if (start > end) {
                return pathSepar;
            }

            path = path.substring(start, end+1);
            // This one does not start or end with pathSepar.
        }

        // ---

        let twoPasses = false;
        let buffer = "";

        let parts = path.split(pathSepar);
        for (let part of parts) {
            if (part.length === 0 || 
                "." === part) { // e.g. "x//y", "x/./y"
                continue;
            }

            if (part.trim().length === 0) {
                // Malformed path. Give up.
                return originalPath;
            }

            if (buffer.length > 0) {
                buffer += pathSepar;
            }
            buffer += part;

            if (".." === part) {
                twoPasses = true;
            }
        }
        
        path = buffer;
        
        // ---
        
        if (twoPasses) {
            parts = path.split(pathSepar);
            buffer = "";

            for (let i = parts.length-1; i >= 0; --i) {
                let part = parts[i];

                if (".." === part) {
                    // Count skipped levels: (i-j).

                    let j = i;
                    while (j >= 0 && ".." === parts[j]) {
                        --j;
                    }

                    // Skip the ".." and the corresponding level: 2*(i-j).
                    // (+1 because the for loop includes a --i.)

                    i = i - 2*(i-j) + 1;

                    if (i < 0) {
                        // Malformed path. Give up.
                        return originalPath;
                    }
                } else {
                    if (buffer.length > 0) {
                        buffer = pathSepar + buffer;
                    }
                    buffer = part + buffer;
                }
            }
        }
        
        path = buffer;
        
        // ---
        
        if (prependSlash) {
            path = pathSepar + path;
        }
        if (appendSlash) {
            path = path + pathSepar;
        }

        return path;
    }
    
    static testIsAbsolute(drive, checkedPath, pathSepar) {
        if (pathSepar === WINDOWS_FILE_PATH_SEPARATOR && drive === null) {
            return false;
        }
        return checkedPath.startsWith(pathSepar);
    }
    
    // -----------------------------------------------------------------------
    // Low-level path utilities.
    // (Work for platform specific file and URI paths.
    //  Work for relative and absolute paths.)
    // -----------------------------------------------------------------------

    /**
     * Tests whether specified path is absolute or relative.
     * <p>On Windows, a drive relative path like "\foo\bar" is considered 
     * to be relative. Only "C:\foo\bar" or "\\my-server\foo\bar" are 
     * considered to be absolute.
     * 
     * @param {string} path - relative or absolute path.
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs).
     * @return {boolean} <code>true</code> if absolute; 
     * <code>false</code> if relative.
     */
    static isAbsolutePath(path, pathSepar) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);
        return URIUtil.testIsAbsolute(drive, checkedPath, pathSepar);
    }
    
    /**
     * Resolves specified path against specified base.
     * <p><strong>IMPORTANT:</strong> a directory path is expected to end 
     * with <code><i>pathSepar</i></code>.
     * 
     * @param {string} path - relative or absolute path.
     * @param {string} basePath - relative or absolute path.
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs).
     * @return {string} resolved path.
     */
    static resolvePath(path, basePath, pathSepar) {
        if (URIUtil.isAbsolutePath(path, pathSepar) ||
           !basePath) {
            return path;
        }

        let parentPath;
        if (basePath.endsWith(pathSepar)) {
            // Assume basePath points to a directory.
            parentPath = basePath;
        } else {
            parentPath = URIUtil.pathParent(basePath, pathSepar,
                                            /*trailingSepar*/ true);
        }

        return URIUtil.normalizePath(!parentPath? path : parentPath + path,
                                     pathSepar);
    }

    /**
     * Returns specified relative or absolute path after normalizing it, 
     * that is, after removing ".", ".." and duplicate path separators from it.
     *
     * @param {string} path - relative or absolute path.
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs).
     * @return {string} normalized path or original path if it is not possible
     * to normalize it (examples: "../../foo/bar", "a/ /b/c").
     */
    static normalizePath(path, pathSepar) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);
        if (drive !== null) {
            checkedPath = drive + checkedPath;
        }
        
        return checkedPath;
    }
    
    
    /**
     * Returns the extension of specified path. To make it simple, the
     * substring after last '.', not including last '.'.
     * <ul>
     * <li>Returns <code>null</code> for "<tt>/tmp/test</tt>".
     * <li>Returns the empty string for "<tt>/tmp/test.</tt>".
     * <li>Returns <code>null</code> for "<tt>~/.profile</tt>".
     * <li>Returns "<tt>gz</tt>" for "<tt>/tmp/test.tar.gz</tt>".
     * </ul>
     * 
     * @param {string} path - relative or absolute path possibly 
     * having an extension
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs)
     * @return {string} extension if any; <code>null</code> otherwise
     */
    static pathExtension(path, pathSepar) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);
        
        let dot = URIUtil.indexOfDot(checkedPath, pathSepar);
        if (dot < 0) {
            return null;
        } else {
            return checkedPath.substring(dot+1);
        }
    }
    
    static indexOfDot(checkedPath, pathSepar) {
        let dot = checkedPath.lastIndexOf('.');
        if (dot >= 0) {
            let baseNameStart = checkedPath.lastIndexOf(pathSepar);
            if (baseNameStart < 0) {
                baseNameStart = 0;
            } else {
                ++baseNameStart;
            }

            if (dot <= baseNameStart) {
                dot = -1;
            }
        }
        
        return dot;
    }
    
    /**
     * Returns the base name of specified path. To make it simple, the
     * substring after last '/'.
     * <p>Examples:
     * <ul>
     * <li>Returns the empty string for "<tt>/</tt>".
     * <li>Returns "<tt>foo</tt>" for "<tt>foo</tt>".
     * <li>Returns "<tt>bar</tt>" for "<tt>/foo/bar</tt>".
     * <li>Returns "<tt>bar</tt>" for "<tt>/foo/bar/</tt>".
     * </ul>
     * 
     * @param {string} path - relative or absolute path
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs)
     * @return {string} base name of specified path
     */
    static pathBasename(path, pathSepar) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);
        
        if (checkedPath === pathSepar) {
            return "";
        }

        // Normalize "/foo/bar/" to "/foo/bar".
        if (checkedPath.endsWith(pathSepar)) {
            checkedPath = checkedPath.substring(0, checkedPath.length-1);
        }

        let slash = checkedPath.lastIndexOf(pathSepar);
        if (slash < 0) {
            return checkedPath;
        }

        return checkedPath.substring(slash+1);
    }
    
    /**
     * Returns the parent of specified path. To make it simple, 
     * the substring before last '/'.
     * <p>Examples:
     * <ul>
     * <li>Returns <code>null</code> for "<tt>/</tt>".
     * <li>Returns <code>null</code> for "<tt>foo</tt>".
     * <li>Returns "<tt>/</tt>" for "<tt>/foo</tt>".
     * <li>Returns "<tt>/foo</tt>" (or "<tt>/foo/</tt>") for 
     * "<tt>/foo/bar</tt>".
     * <li>Returns "<tt>/foo</tt>" (or "<tt>/foo/</tt>") for 
     * "<tt>/foo/bar/</tt>".
     * </ul>
     * 
     * @param {string} path - relative or absolute path
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs)
     * @param {string} trailingSepar - if <code>true</code>, 
     * returned path ends with a trailing <code>pathSepar</code> character
     * @return {string} parent of specified path or 
     * <code>null</code> for <code>pathSepar</code> or if specified path 
     * does not contain the <code>pathSepar</code> character
     */
    static pathParent(path, pathSepar, trailingSepar) {
        let [drive, checkedPath] = URIUtil.checkPath(path, pathSepar);
        
        if (checkedPath === pathSepar) {
            return null;
        }

        // Normalize "/foo/bar/" to "/foo/bar".
        if (checkedPath.endsWith(pathSepar)) {
            checkedPath = checkedPath.substring(0, checkedPath.length-1);
        }

        let slash = checkedPath.lastIndexOf(pathSepar);
        if (slash < 0) {
            return null;
        }

        let parent;
        if (slash === 0) {
            // Example: "/foo"
            parent = pathSepar;
        } else {
            parent = checkedPath.substring(0, trailingSepar? slash+1 : slash);
        }

        if (drive !== null) {
            parent = drive + parent;
        }
        
        return parent;
    }

    /**
     * Returns specified path after making it relative to the other path
     * (when this is possible).
     * <p><strong>IMPORTANT:</strong> a directory path is expected to end 
     * with <code><i>pathSepar</i></code>.
     * 
     * @param {string} path - an absolute path.
     * @param {string} basePath - another absolute path which is used as a base.
     * @param {string} pathSepar - the character used to separate 
     * path segments (that is, '/' for URIs)
     * @return {string} the relative path when possible; 
     * specified path as is otherwise (Windows example: when specified paths
     * don't have the same drive.
     */
    static relativizePath(path, basePath, pathSepar) {
        // path and basePath must be absolute paths having the same drive (if
        // any).
        let [drive1, checkedPath1] = URIUtil.checkPath(path, pathSepar);
        let [drive2, checkedPath2] = URIUtil.checkPath(basePath, pathSepar);
        if (!URIUtil.testIsAbsolute(drive1, checkedPath1, pathSepar) ||
            !URIUtil.testIsAbsolute(drive2, checkedPath2, pathSepar) ||
            drive1 !== drive2) {
            return path;
        }
        path = checkedPath1;
        basePath = checkedPath2;
        
        if (pathSepar === path) {
            return pathSepar;
        }

        if (pathSepar === basePath) {
            return path.substring(1);
        }

        if (!basePath.endsWith(pathSepar)) {
            basePath = URIUtil.pathParent(basePath, pathSepar,
                                          /*trailingSepar*/ true);
        }

        // Windows filenames are case insensitive. 
        const onWindows = (pathSepar === WINDOWS_FILE_PATH_SEPARATOR);
        const upSegment = ".." + pathSepar;
        
        let relPath = "";
        while (basePath !== null) {
            let start = basePath;

            if (path.startsWith(start) ||
                (onWindows &&
                 path.toLowerCase().startsWith(start.toLowerCase()))) {
                relPath += path.substring(start.length);
                break;
            }

            relPath += upSegment;
            basePath = URIUtil.pathParent(basePath, pathSepar,
                                          /*trailingSepar*/ true);
        }

        return relPath;
    }
    
    // -----------------------------------------------------------------------

    static formatFileSize(byteCount, fallback=null) {
        if (!Number.isInteger(byteCount) || byteCount < 0) {
            return fallback;
        }
        
        let unit = 0;
        while (byteCount >= 1024) {
            byteCount /= 1024;
            unit++;
        }

        if (unit > 0) {
            byteCount = byteCount.toFixed(1); 
        }
        return `${byteCount} ${" KMGTPEZY"[unit]}B`;
    }
    
    static formatFileDate(millis, fallback=null) {
        if (!Number.isInteger(millis) || millis < 0) {
            return fallback;
        }

        const date = new Date(millis);
        
        let text = date.getFullYear();
        text += "-";
        text += URIUtil.formatFileDateField(1 + date.getMonth());
        text += "-";
        text += URIUtil.formatFileDateField(date.getDate());
        text += " ";
        text += URIUtil.formatFileDateField(date.getHours());
        text += ":";
        text += URIUtil.formatFileDateField(date.getMinutes());
        
        return text;
    }
    
    static formatFileDateField(value) {
        let text = String(value);
        while (text.length < 2) {
            text = "0" + text;
        }
        return text;
    }
    
    // -----------------------------------------------------------------------
    
    /*TEST|
    static test_URIUtil(logger, filePathSepar) {
        const uriList = [
          "",
          "foo/bar.dat",
          "/foo/bar.dat",
          "file:/foo/bar.xml",
          "file:///foo/bar.xml",
          "file:/Z:/foo/bar.xml#end",
          "file:/C:",
          "file:///C:/foo/././gee//wiz//bar.xml",
          "file://GOOD/foo/gee/wiz/../../bar.xml",
          "file://BAD/foo/gee/wiz/../../../../bar.xml",
          "file://BAD/foo/%20%20%20%20/bar.xml",

          "http://www.uiuc.edu:80",
          "http://www.uiuc.edu:80/",
          "http://www.uiuc.edu:80/SDG/",
          "http://www.uiuc.edu:80/SDG/Software/Mosaic/Demo/url-primer",
          "http://www.uiuc.edu:80/SDG/Software/Mosaic/Demo/.url-primer",
          "http://www.uiuc.edu:80/SDG/Software/Mosaic/Demo/url-primer.html",
          "http://www.uiuc.edu/SDG/Software/Mosaic/Demo/url-primer.html.gz",
          "http://127.0.0.1/SDG/Software/Mosaic/Demo/url-primer.html#chapter1",
          "foo://example.com:8042/over/there?name=ferret#nose",
          "urn:example:animal:ferret:nose",
          "urn:example:animal:ferret:nose?name=ferret#foo",
          "ftp://hs%40x:my%20pass@www.foo.com:1024/bar/\
vin%20ros%C3%A9.jar#ros%C3%A9",

          "csri://(file)@no-authority/",
          "csri://(file)@no-authority/home/hussein/",
          "csri://(file)@no-authority/home//hussein/./tmp/..////",
          "csri://(file)@no-authority/../.././doc.xml",
          "csri://(file)@no-authority/z:/docs/doc.xml",
          "csri://(file)@myserver/share/docs/doc.xml",
          "csri://(file)@192.168.1.205/tmp/dump.dat",
          "csri://(file)@[1080:0:0:0:8:800:200C:417A]:80/tmp/dump.dat",
          "csri://(xdocs)@MyServer/Content/sample/dita/tasks/\
Very%20Important.dita",
          "csri://(https)@www.xmlmind.com/xmleditor/index.html#notice",
          "csri://(http)@www.xmlmind.com:80/xslsrv/exec?op=jobs&auth=toto",
          "csri://(ftp)john:doe@localhost/src/test/makefile",
          "csri://(urn)@opaque-uri/ietf:rfc:2648",
          "csri://(urn)@opaque-uri/lex:eu:council:directive:\
2010-03-09;2010-19-UE#summary",
        ];

        for (let uri of uriList) {
            let [scheme, authority, path, query, fragment] =
                URIUtil.splitURI(uri);

            let abs = URIUtil.isAbsoluteURI(uri);
            let name = URIUtil.uriBasename(uri);
            let ext = URIUtil.uriExtension(uri);
            let parent = URIUtil.uriParent(uri);
            
            // File path tests ---

            let uri2 = URIUtil.csriURLToURI(uri);
            let file = URIUtil.uriToFilePath(uri2, filePathSepar);

            let uri3 = null;
            if (file !== null) {
                uri3 = URIUtil.pathToFileURI(file, filePathSepar);
            }

            logger(`uri='${uri}':\n\
    splitURI=[scheme=${scheme}, authority=${authority}, path=${path}, \
query=${query}, fragment=${fragment}],\n\
    abs=${abs}, basename='${name}', extension='${ext}', parent='${parent}'\n\
    csriURLToURI(uri)=uri2=${uri2}\n\
    uriToFilePath(uri2,${filePathSepar})=${file}\n\
    pathToFileURI(file,${filePathSepar})=${uri3}
---`);
        }
    }
    |TEST*/
    
    // -----------------------------------------------------------------------
    
    /*TEST|
    static test_relativize(logger) {
        const paths1 = [
            "", "", 
            "/", "/", 
            "/", "/foo", 
            "/foo", "/", 
            "/foo", "/bar", 
            "foo", "/bar", 
            "/foo", "bar", 
            "/home/hussein", "/home",
            "/home/hussein", "/home/",
            "/foo/gee", "/foo/gee/wiz.xml", 
            "/foo/zip/bar.xml", "/foo/gee/wiz.xml", 
            "/foo/zip/bar.xml", "/foo/wiz.xml", 
            "/foo/bar.xml", "/foo/gee/wiz.xml", 
            "/foo/zip/bar.xml", "/foo/zip/bar.xml", 
            "/usr/local/bin/html2ps", "/usr/bin/grep", 
            "/foo/zip/bar/", "/foo/wiz", 
            "/foo/bar", "/foo/gee/wiz/", 
            "/foo/zip/bar/", "/foo/wiz/", 
            "/foo/bar/", "/foo/gee/wiz/", 
            "/foo/bar/", "/foo/bar/", 
            "/foo/bar/", "/foo/bar/gee", 
            "/foo/bar/gee", "/foo/bar/", 
            "/foo/bar/", "/foo/bar/wiz/", 
            "/foo/bar/wiz/", "/foo/bar/",
        ];
        URIUtil.testRelativizePath(logger, paths1, '/');
        
        logger("---");
        
        const paths2 = [
            "Z:", "Z:",
            "C:\\", "C:\\",
            "C:\\home\\hussein", "c:\\home",
            "C:\\home\\hussein", "c:\\home\\",
            "C:\\", "C:\\home\\hussein",
            "C:\\home\\hussein", "C:\\",
            "C:\\home", "c:\\home\\hussein",
            "c:\\home\\hussein", "C:\\HOME",
            "C:\\HOME\\HUSSEIN", "c:\\home",
            "C:\\home\\hussein\\DOT emacs", "C:\\home\\hussein\\bin\\clean.bat",
            "C:\\HOME\\HUSSEIN\\BIN\\CLEAN.BAT", "c:\\home\\hussein\\DOT emacs",
            "C:\\home\\hussein\\DOT emacs", "C:\\home\\hussein\\bin\\",
            "C:\\home\\hussein\\bin\\", "C:\\home\\hussein\\DOT emacs",
            "C:\\home\\hussein\\bin", "Z:\\docsrc",
            "Z:\\bin\\clean", "C:\\home\\hussein\\bin\\clean.bat",
            
            "\\\\Vboxsrv\\home_hussein\\bin\\clean",
            "\\\\Vboxsrv\\home_hussein\\.profile",
            
            "\\\\Vboxsrv\\home_hussein\\.profile",
            "\\\\VBOXSRV\\HOME_HUSSEIN\\BIN\\CLEAN",
        ];
        URIUtil.testRelativizePath(logger, paths2, '\\');
        
        logger("---");

        const uris = [
            "file:/Users/john/docs/doc.xml",
            "file:/Users/john/",

            "file:/Users/john/docs/doc.xml",
            "file:///Users/john/",

            "file:/Users/john/docs/doc.xml",
            "file://localhost/Users/john/",

            "file:/C:/Users/john/docs/doc.xml",
            "file:/Z:/docs/",

            "http://www.acme.com/docs/toc.html",
            "https://www.acme.com/docs/",
            
            "http://www.acme.biz/docs/toc.html",
            "http://www.acme.com/docs/",
            
            "http://www.acme.com/docs/toc.html",
            "http://www.acme.com/docs/",
            
            "http://www.acme.com/",
            "http://www.acme.com/docs/toc.html",
            
            "http://www.acme.com/docs/toc.html",
            "http://www.acme.com/",
            
            "http://www.acme.com/docs/toc.html",
            "http://www.acme.com/docs/toc.html",
            
            "http://www.acme.com/index.html",
            "http://www.acme.com/docs/toc.html",
            
            "http://www.acme.com/docs/index.html#index",
            "http://www.acme.com/docs/toc.html",
            
            "http://www.acme.com/docs/images/logo?format=SVG",
            "http://www.acme.com/docs/toc.html",
            
            "http://www.acme.com/index.html",
            "http://www.acme.com/docs/chapters/",
            
            "http://www.acme.com/docs/chapters/chapter1.html",
            "http://www.acme.com/docs/toc.html",
        ];
        for (let i = 0; i < uris.length; i += 2) {
            let uri = uris[i];
            let baseURI = uris[i+1];

            let relURI = URIUtil.relativizeURI(uri, baseURI);
            let absURI = URIUtil.resolveURI(relURI, baseURI);
            logger(`"${uri}" relative to "${baseURI}" = "${relURI}"\n\
    relative uri resolved = "${absURI}"`);
        }
    }

    static testRelativizePath(logger, paths, pathSepar) {
        for (let i = 0; i < paths.length; i += 2) {
            let path = paths[i];
            let basePath = paths[i+1];

            let relPath = URIUtil.relativizePath(path, basePath, pathSepar);
            let absPath = URIUtil.resolvePath(relPath, basePath, pathSepar);
            logger(`"${path}" relative to "${basePath}" = "${relPath}"\n\
    relative path resolved = "${absPath}"`);
        }
    }
    |TEST*/
}