/**
* This file is part of muCommander, http://www.mucommander.com
* Copyright (C) 2002-2016 Maxence Bernard
*
* muCommander is free software; you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* muCommander is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.mucommander.commons.file.util;
import com.mucommander.commons.file.AbstractFile;
import com.mucommander.commons.file.FileFactory;
import com.mucommander.commons.file.FileURL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.MalformedURLException;
/**
* This class contains static helper methods that operate on file paths.
*
* @author Maxence Bernard
*/
public class PathUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(PathUtils.class);
/**
* This class represents a destination entered by the user and resolved by {@link PathUtils#resolveDestination(String, com.mucommander.commons.file.AbstractFile)}
* into an <code>AbstractFile</code> and a destination type.
*
* @see PathUtils#resolveDestination(String, com.mucommander.commons.file.AbstractFile)
*/
public static class ResolvedDestination {
/** The destination AbstractFile, may be a regular file or a folder */
private AbstractFile file;
/** The destination's folder, the file itself for {@link #EXISTING_FOLDER}, the destination file's parent for
* other types */
private AbstractFile folder;
/** The destination type, see constant values */
private int type;
/** Designates a folder, either a directory or archive, that exists on the filesystem. */
public final static int EXISTING_FOLDER = 0;
/** Designates a regular file that exists on the filesystem. The file may be a browsable archive but that was
* refered to as a regular file, i.e. without a trailing separator character in the path. */
public final static int EXISTING_FILE = 1;
/** Designates a new file that doesn't exist on the filesystem. The file's parent however does always exist. */
public final static int NEW_FILE = 2;
/**
* Creates a new <code>ResolvedDestination</code> with the specified destination file and type.
*
* @param destinationFile the destination file
* @param destinationType the destination type
* @param destinationFolder the destination folder
*/
private ResolvedDestination(AbstractFile destinationFile, int destinationType, AbstractFile destinationFolder) {
this.file = destinationFile;
this.type = destinationType;
this.folder = destinationFolder;
}
/**
* Returns the resolved destination file. The returned file may or may not physically exist on the filesystem.
* If it exists, the returned file may be a folder (directory or browsable archive) or a regular file.
*
* @return the resolved destination file
* @see #getDestinationType()
*/
public AbstractFile getDestinationFile() {
return file;
}
/**
* Returns the resolved destination's folder. Depending on the {@link #getDestinationType() destination type},
* the destination folder is:
* <dl>
* <dt>for {@link #EXISTING_FOLDER}</dt><dd>the {@link #getDestinationFile() destination file} itself</dd>
* <dt>for {@link #EXISTING_FILE} or {@link #NEW_FILE}</dt><dd>the {@link #getDestinationFile() destination file}'s parent</dd>
* </dl>
* The returned <code>AbstractFile</code> is always a folder that exists.
*
* @return the resolved destination file
* @see #getDestinationType()
*/
public AbstractFile getDestinationFolder() {
return folder;
}
/**
* Returns the type of destination that was resolved. The returned value will be one of the following constant
* fields defined in this class:
* <dl>
* <dt>{@link #EXISTING_FOLDER}</dt><dd>if the path denotes a folder, either a directory or a browsable
* archive.</dd>
* <dt>{@link #EXISTING_FILE}</dt><dd>if the path denotes a regular file. The file may be a browsable archive,
* see below.</dd>
* <dt>{@link #NEW_FILE}</dt><dd>if the path denotes a non-existing file whose parent exists.</dd>
* </dl>
* Paths to browsable archives are considered as denoting a folder only if they end with a trailing separator
* character. If they don't, they're considered as denoting a regular file. For example,
* <code>/existing_folder/existing_archive.zip/</code> refers to the archive as a folder where as
* <code>/existing_folder/existing_archive.zip</code> refers to the archive as a regular file.
*
* @return the type of destination that was resolved
*/
public int getDestinationType() {
return type;
}
}
/**
* Resolves a destination path entered by the user and returns a {@link ResolvedDestination} object that
* that contains a {@link AbstractFile} instance corresponding to the path and a type that describes the kind of
* destination that was resolved. <code>null</code> is returned if the path is not a valid destination (see below)
* or could not be resolved, for example becuase of I/O or authentication error.
* <p>
* The given path may be either absolute or relative to the specified base folder. If the base folder argument is
* <code>null</code> and the path is relative, <code>null</code> will always be returned.
* The path may contain '.', '..' and '~' tokens which will be left for the corresponding
* {@link com.mucommander.commons.file.SchemeParser} to canonize.
* </p>
* <p>
* The path may refer to the following listed destination types. In all cases, the destination's parent folder must
* exist, if it doesn't <code>null</code> will always be returned. For example, <code>/non_existing_folder/file</code>
* is not a valid destination (provided that '/non_existing_folder' does not exist).
* <dl>
* <dt>{@link ResolvedDestination#EXISTING_FOLDER}</dt><dd>if the path denotes a folder, either a directory or a
* browsable archive.</dd>
* <dt>{@link ResolvedDestination#EXISTING_FILE}</dt><dd>if the path denotes a regular file. The file may be a browsable archive,
* see below.</dd>
* <dt>{@link ResolvedDestination#NEW_FILE}</dt><dd>if the path denotes a non-existing file whose parent exists.</dd>
* </dl>
* Paths to browsable archives are considered as denoting a folder only if they end with a trailing separator
* character. If they don't, they're considered as denoting a regular file. For example,
* <code>/existing_folder/existing_archive.zip/</code> refers to the archive as a folder where as
* <code>/existing_folder/existing_archive.zip</code> refers to the archive as a regular file.
* </p>
*
* @param destPath the destination path to resolve
* @param baseFolder the base folder used for relative paths, <code>null</code> to accept only absolute paths
* @return the object that that contains a {@link AbstractFile} instance corresponding to the path and a type that
* describes the kind of destination that was resolved
*/
public static ResolvedDestination resolveDestination(String destPath, AbstractFile baseFolder) {
AbstractFile destFile;
FileURL destURL;
// Try to resolve the path as a URL
try {
destURL = FileURL.getFileURL(destPath);
// destPath is absolute
}
catch(MalformedURLException e) {
// destPath is relative (or malformed)
// Abort now if there is no base folder
if(baseFolder==null)
return null;
String separator = baseFolder.getSeparator();
// Start by cloning the base folder's URL, including credentials and properties
FileURL baseFolderURL = baseFolder.getURL();
destURL = (FileURL)baseFolderURL.clone();
String basePath = destURL.getPath();
if(!destPath.equals(""))
destURL.setPath(basePath + (basePath.endsWith(separator)?"":separator) + destPath);
// At this point we have the proper URL, except that the path may contain '.', '..' or '~' tokens.
// => parse the URL from scratch to have the SchemeParser canonize them.
try {
destURL = FileURL.getFileURL(destURL.toString(false));
// Import credentials separately, so that login and passwords that contain URI-unsafe characters
// such as '/' are properly parsed.
destURL.setCredentials(baseFolderURL.getCredentials());
destURL.importProperties(baseFolderURL);
}
catch(MalformedURLException e2) {
return null;
}
}
// No point in going any further if the URL cannot be resolved into a file
destFile = FileFactory.getFile(destURL);
if(destFile ==null) {
LOGGER.info("could not resolve a file for {}", destURL);
return null;
}
// Test if the destination file exists
boolean destFileExists = destFile.exists();
if(destFileExists) {
// Note: path to archives must end with a trailing separator character to refer to the archive as a folder,
// if they don't, they'll refer to the archive as a file.
if(destFile.isDirectory() || (destPath.endsWith(destFile.getSeparator()) && destFile.isBrowsable()))
return new ResolvedDestination(destFile, ResolvedDestination.EXISTING_FOLDER, destFile);
}
// Test if the destination's parent exists, if not the path is not a valid destination
AbstractFile destParent = destFile.getParent();
if(destParent==null || !destParent.exists())
return null;
return new ResolvedDestination(destFile, destFileExists?ResolvedDestination.EXISTING_FILE:ResolvedDestination.NEW_FILE, destParent);
}
/**
* Removes any leading separator character (slash or backslash) from the given path and returns the modified path.
*
* @param path the path to modify
* @return the modified path, free of any leading separator
*/
public static String removeLeadingSeparator(String path) {
char firstChar;
if(path.length()>0 && ((firstChar=path.charAt(0))=='/' || firstChar=='\\'))
return path.substring(1, path.length());
return path;
}
/**
* Removes any leading separator character from the given path and returns the modified path.
*
* @param path the path to modify
* @param separator the path separator, usually "/" or "\\"
* @return the modified path, free of any leading separator
*/
public static String removeLeadingSeparator(String path, String separator) {
if(path.startsWith(separator))
return path.substring(separator.length(), path.length());
return path;
}
/**
* Removes any trailing separator character (slash or backslash) from the given path and returns the modified path.
*
* @param path the path to modify
* @return the modified path, free of any trailing separator
*/
public static String removeTrailingSeparator(String path) {
char lastChar;
int len = path.length();
if(len>0 && ((lastChar=path.charAt(len-1))=='/' || lastChar=='\\'))
return path.substring(0, len-1);
return path;
}
/**
* Removes any trailing separator character (slash or backslash) from the given path and returns the modified path.
*
* @param path the path to modify
* @param separator the path separator, usually "/" or "\\"
* @return the modified path, free of any trailing separator
*/
public static String removeTrailingSeparator(String path, String separator) {
if(path.endsWith(separator))
return path.substring(0, path.length()-separator.length());
return path;
}
/**
* Returns <code>true</code> if both specified paths are equal. The path comparison is case-sensitive but trailing
* separator-insensitive: if the sole difference between two paths is a trailing path separator, they will be
* considered as equal. For example, <code>/path</code> and <code>/path/</code> are considered equal, assuming the
* path separator is '/'.
* <p>
* If any of the two specified paths is <code>null</code>, then the other one must also be <code>null</code> for
* this method to return <code>true</code>. The given <code>separator</code> must never be <code>null</code> or
* a {@link NullPointerException} will be thrown.
* </p>
*
* @param path1 first path to test
* @param path2 second path to test
* @param separator path separator for both paths
* @throws NullPointerException if the given separator is <code>null</code>
* @return <code>true</code> if both paths are equal
*/
public static boolean pathEquals(String path1, String path2, String separator) {
if(path1==null)
return path2==null;
if(path2==null)
return path1==null;
if(path1.equals(path2))
return true;
int len1 = path1.length();
int len2 = path2.length();
int separatorLen = separator.length();
// If the difference between the 2 strings is just a trailing path separator, we consider the paths as equal
if(Math.abs(len1-len2)==separatorLen && (len1>len2 ? path1.startsWith(path2) : path2.startsWith(path1))) {
String diff = len1>len2 ? path1.substring(len1-separatorLen) : path2.substring(len2-separatorLen);
return separator.equals(diff);
}
return false;
}
/**
* Returns a hashcode for the given path. The returned hashcode is consistent with
* {@link #pathEquals(String, String, String)} in that hashcodes are trailing separator-invariant:
* <code>path1.equals(path2)</code> implies <code>path1Hashcode==path2Hashcode.hashCode()</code>, even if path1
* ends with a separator and path2 does not or vice-versa.
*
* @param path the path for which to return a hashcode
* @param separator separator of the given path
* @return a trailing separator-insensitive hashcode
*/
public static int getPathHashCode(String path, String separator) {
// #equals(Object) is trailing separator insensitive, so the hashCode must be trailing separator invariant
return path.endsWith(separator)
?path.substring(0, path.length()-separator.length()).hashCode()
:path.hashCode();
}
/**
* Removes the specified number of fragments from the beginning of the given path and returns the modified path,
* free of a leading separator. Returns an empty string (<code>""</code>) if the path does not contain less or
* exactly that many fragments.
*
* <p>
* For instance, calling this method with
* <ul>
* <li><code>("/home/maxence/, "/", 0)</code> will return "home/maxence/"</li>
* <li><code>("/home/maxence/, "/", 1)</code> will return "maxence/"</li>
* <li><code>("/home/maxence/, "/", 2)</code> will return ""</li>
* <li><code>("/home/maxence/, "/", 3)</code> will return ""</li>
* </ul>
* </p>
*
* @param path the path to modify
* @param separator the path separator, usually "/" or "\\"
* @param nbFragments number of path fragments to remove from the path
* @return the modified path, free of any leading separator
*/
public static String removeLeadingFragments(String path, String separator, int nbFragments) {
path = removeLeadingSeparator(path, separator);
if(nbFragments==0)
return path;
int pos=-1;
for(int i=0; i<nbFragments && (pos=path.indexOf(separator, pos+1))!=-1; i++);
if(pos==-1 || pos==path.length()-1)
return "";
return path.substring(pos+1, path.length());
}
/**
* Returns the depth of the specified path, based on the number of path separators it contains, excluding those
* occurring at the beginning and at the end. The minimum depth of a path is 0.<br/>
* Here are a few examples when the path separator is <code>"/"</code>:
* <dl>
* <dt>/</dt><dd>0</dd>
* <dt>/home</dt><dd>1</dd>
* <dt>/home/maxence</dt><dd>2</dd>
* </dl>
*
* <p>
* It is worth noting that this method relies strictly on the occurences of path separators and nothing else.
* Therefore, Windows-like paths that start with a drive letter will always have a minimum depth
* of 1.<br/>
* Here are a few examples when the path separator is <code>"\\"</code>:
* <dl>
* <dt>C:\\</dt><dd>1</dd>
* <dt>C:\\home</dt><dd>2</dd>
* <dt>C:\\home\\maxence</dt><dd>1</dd>
* </dl>
* </p>
*
* @param path the path for which to calculate the depth
* @param separator the path separator, usually "/" or "\\"
* @return the depth of the given path
*/
public static int getDepth(String path, String separator) {
if(path.equals("") || path.equals(separator))
return 0;
int depth = 1;
int pos = path.startsWith(separator)?1:0;
while ((pos=path.indexOf(separator, pos+1))!=-1)
depth++;
if(path.endsWith(separator))
depth--;
return depth;
}
}