/**
* 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 java.util.Comparator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* FileComparator compares {@link AbstractFile} instances using a comparison criterion order (ascending or descending).
* Directories can either be mixed with regular files (compared just as regular files), or always precede regular files.
* <p>FileComparator extends Comparator and thus can be used wherever a Comparator is accepted. In particular, it
* can be used with <code>java.util.Arrays</code> sort methods to easily sort an array of files.
*
* <p>The following criteria are available:
* <ul>
* <li>{@link #NAME_CRITERION}: compares filenames returned by {@link AbstractFile#getName()}
* <li>{@link #SIZE_CRITERION}: compares file sizes returned by {@link AbstractFile#getSize()}. Note: size for
* directories is always considered as 0, even if {@link AbstractFile#getSize()} returns something else.
* <li>{@link #DATE_CRITERION}: compares file dates returned by {@link AbstractFile#getDate()}
* <li>{@link #EXTENSION_CRITERION}: compares file extensions returned by {@link AbstractFile#getExtension()}
* <li>{@link #PERMISSIONS_CRITERION}: compares file permissions returned by {@link AbstractFile#getPermissions()}
* </ul>
*
* @author Maxence Bernard
*/
public class FileComparator implements Comparator<AbstractFile> {
/** Comparison criterion */
private int criterion;
/** Ascending or descending order ? */
private boolean ascending;
/** Specifies whether directories should precede files or be handled as regular files */
private boolean directoriesFirst;
/** Criterion for filename comparison. */
public final static int NAME_CRITERION = 0;
/** Criterion for file size comparison. */
public final static int SIZE_CRITERION = 1;
/** Criterion for file date comparison. */
public final static int DATE_CRITERION = 2;
/** Criterion for file extension comparison. */
public final static int EXTENSION_CRITERION = 3;
/** Criterion for file permissions comparison. */
public final static int PERMISSIONS_CRITERION = 4;
/** Criterion for owner comparison. */
public final static int OWNER_CRITERION = 5;
/** Criterion for group comparison. */
public final static int GROUP_CRITERION = 6;
/** Matches filenames that contain a number, like "01 - Do the Joy.mp3" */
private final static Pattern FILENAME_WITH_NUMBER_PATTERN = Pattern.compile("\\d+");
/**
* Creates a new FileComparator using the specified comparison criterion, order (ascending or descending) and
* directory handling rule.
*
* @param criterion comparison criterion, see constant fields
* @param ascending if true, ascending order will be used, descending order otherwise
* @param directoriesFirst specifies whether directories should precede files or be handled as regular files
*/
public FileComparator(int criterion, boolean ascending, boolean directoriesFirst) {
this.criterion = criterion;
this.ascending = ascending;
this.directoriesFirst = directoriesFirst;
}
/**
* Returns a <code>value</code> for the given character. Using this function in a comparator will separator
* symbols for digits and letters and put in the following order:
* <ul>
* <li>symbols first</li>
* <li>digits second</li>
* <li>letters third</li>
* </ul>
*
* <p>This character order was suggested in ticket #282.</p>
*
* @param c character for which to return a value
* @return a <code>value</code> for the given character
*/
private int getCharacterValue(int c) {
// Note: max char value is 65535
if(Character.isLetter(c))
c += 131070; // yields a value higher than any other symbol or digit
else if(Character.isDigit(c))
c += 65535; // yields a value higher than any other symbol
// else we have a symbol
return c;
}
/**
* Removes leading zeros ('0') from the given string (if it contains any), and returns the trimmed string.
*
* @param s the string from which to remove leading zeros
* @return a string without leading zeros
*/
private String removeLeadingZeros(String s) {
int len = s.length();
int i=0;
while(i<len && s.charAt(i)=='0')
i++;
if(i>0)
return s.substring(i, len);
return s;
}
/**
* Compare the specified strings, following the contract of {@link Comparator#compare(Object, Object)}.
*
* @param s1 first string to compare
* @param s2 second string to compare.
* @param ignoreCase <code>true</code> to perform a case-insensitive string comparison, <code>false</code> to take
* the case into account.
* @param nullProtection <code>true</code> if any of s1 or s2 can be <code>null</code>, <code>false</code>
* if strings cannot be <code>null</code>.
* @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater
* than the second.
*/
private int compareStrings(String s1, String s2, boolean ignoreCase, boolean nullProtection) {
// Protect against null values, only if requested
if(nullProtection) {
if(s1==null && s2!=null) // s1 is null, s2 isn't
return -1;
else if(s1!=null && s2==null) // s2 is null, s1 isn't
return 1;
// At this point, either both strings are null, or none of them are
else {
if (s1==null) // Both strings are null
return 0;
// else: Both strings are not null, go on with the comparison
}
}
// Special treatment for strings that contain a number, so they are ordered by the number's value, e.g.:
// 1 < 1a < 2 < 10, like Mac OS X Finder and Windows Explorer do.
//
// This special order applies only if both strings contain a number and have the same prefix. Otherwise, the general order applies.
Matcher m1 = FILENAME_WITH_NUMBER_PATTERN.matcher(s1);
if(m1.find()) {
Matcher m2 = FILENAME_WITH_NUMBER_PATTERN.matcher(s2);
if(m2.find()) {
// So we got two filenames that both contain a number, check if they have the same prefix
int start1 = m1.start();
int start2 = m2.start();
// Note: compare prefixes only if start indexes match, faster that way
if(start1==start2 && (start1==0 || s1.substring(0, start1).equals(s2.substring(0, start2)))) {
String g1 = removeLeadingZeros(m1.group());
String g2 = removeLeadingZeros(m2.group());
int g1Len = g1.length();
int g2Len = g2.length();
if(g1Len!=g2Len)
return g1Len - g2Len;
int c1, c2;
for (int i=0; i<g1Len && i<g2Len; i++) {
c1 = g1.charAt(i);
c2 = g2.charAt(i);
if(c1 != c2)
return c1 - c2;
}
}
}
}
int n1 = s1.length();
int n2 = s2.length();
for (int i=0; i<n1 && i<n2; i++) {
int c1 = s1.charAt(i);
int c2 = s2.charAt(i);
if(ignoreCase) {
if (c1 != c2) {
c1 = Character.toUpperCase(c1);
c2 = Character.toUpperCase(c2);
if (c1 != c2) {
// Quote from String#regionsMatches:
// "Unfortunately, conversion to uppercase does not work properly for the Georgian alphabet, which
// has strange rules about case conversion. So we need to make one last check before exiting."
c1 = Character.toLowerCase(c1);
c2 = Character.toLowerCase(c2);
if (c1 != c2)
return getCharacterValue(c1) - getCharacterValue(c2);
}
}
}
else if (c1 != c2) {
return getCharacterValue(c1) - getCharacterValue(c2);
}
}
return n1 - n2;
}
///////////////////////////////
// Comparator implementation //
///////////////////////////////
public int compare(AbstractFile f1, AbstractFile f2) {
long diff;
boolean is1Directory = f1.isDirectory();
boolean is2Directory = f2.isDirectory();
if(directoriesFirst) {
if(is1Directory && !is2Directory)
return -1; // ascending has no effect on the result (a directory is always first) so let's return
else if(is2Directory && !is1Directory)
return 1; // ascending has no effect on the result (a directory is always first) so let's return
// At this point, either both files are directories or none of them are
}
if (criterion == SIZE_CRITERION) {
// Consider that directories have a size of 0
long fileSize1 = is1Directory?0:f1.getSize();
long fileSize2 = is2Directory?0:f2.getSize();
// Returns file1 size - file2 size, file size of -1 (unavailable) is considered as enormous (max long value)
diff = (fileSize1==-1?Long.MAX_VALUE:fileSize1)-(fileSize2==-1?Long.MAX_VALUE:fileSize2);
}
else if (criterion == DATE_CRITERION) {
diff = f1.getDate()-f2.getDate();
}
else if (criterion == PERMISSIONS_CRITERION) {
diff = f1.getPermissions().getIntValue() - f2.getPermissions().getIntValue();
}
else if (criterion == EXTENSION_CRITERION) {
diff = compareStrings(f1.getExtension(), f2.getExtension(), true, true);
}
else if (criterion == OWNER_CRITERION) {
diff = compareStrings(f1.getOwner(), f2.getOwner(), true, true);
}
else if (criterion == GROUP_CRITERION) {
diff = compareStrings(f1.getGroup(), f2.getGroup(), true, true);
}
else { // criterion == NAME_CRITERION
diff = compareStrings(f1.getName(), f2.getName(), true, false);
if(diff==0) {
// This should never happen unless the current filesystem allows a directory to have
// several files with different case variations of the same name.
// AFAIK, no OS/filesystem allows this, but just to be safe.
// Case-sensitive name comparison
diff = compareStrings(f1.getName(), f2.getName(), false, false);
}
}
if(criterion!=NAME_CRITERION && diff==0) // If both files have the same criterion's value, compare names
diff = compareStrings(f1.getName(), f2.getName(), true, false);
// Cast long value to int, without overflowing the int if the long value exceeds the min or max int value
int intValue;
if(diff>Integer.MAX_VALUE)
intValue = Integer.MAX_VALUE; // 2147483647
else if(diff<Integer.MIN_VALUE+1) // Need that +1 so that the int is not overflowed if ascending order is enabled (i.e. int is negated)
intValue = Integer.MIN_VALUE+1; // 2147483647
else
intValue = (int)diff;
return ascending?intValue:-intValue; // Note: ascending is used more often, more efficient to negate for descending
}
/**
* Returns true only if the given object is a FileComparator using the same criterion and ascending/descending order.
*/
public boolean equals(Object o) {
if(! (o instanceof FileComparator))
return false;
FileComparator fc = (FileComparator)o;
return criterion ==fc.criterion && ascending==fc.ascending;
}
}