/**
* RenameWand 2.2
* Copyright 2007 Zach Scrivena
* 2007-12-09
* zachscrivena@gmail.com
* http://renamewand.sourceforge.net/
*
* RenameWand is a simple command-line utility for renaming files or
* directories using an intuitive but powerful syntax.
*
* TERMS AND CONDITIONS:
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package renamewand;
import java.io.Console;
import java.io.File;
import java.io.PrintWriter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.TreeMap;
import java.util.regex.PatternSyntaxException;
/**
* RenameWand is a simple command-line utility for renaming files or
* directories using an intuitive but powerful syntax.
*/
public class RenameWand
{
/**************************************
* CONSTANTS AND MISCELLANEOUS FIELDS *
**************************************/
/** constant: program title */
private static final String PROGRAM_TITLE =
"RenameWand 2.2 Copyright 2007 Zach Scrivena 2007-12-09";
/** constant: special construct separator character */
private static final char SPECIAL_CONSTRUCT_SEPARATOR_CHAR = '|';
/** constant: substring range character */
private static final char SUBSTRING_RANGE_CHAR = ':';
/** constant: substring delimiter character */
private static final char SUBSTRING_DELIMITER_CHAR = ',';
/** constant: integer filter indicator character */
private static final char INTEGER_FILTER_INDICATOR_CHAR = '@';
/** constant: regex pattern for register names */
private static final Pattern REGISTER_NAME_PATTERN = Pattern.compile(
"[a-zA-Z_][a-zA-Z_0-9]*");
/**
* constant: regex pattern for special construct "<length|@expr>" in source pattern string.
* Match groups: (1,"length"), (2,"@"), (3,"expr")
*/
private static final Pattern SOURCE_SPECIAL_CONSTRUCT_PATTERN = Pattern.compile(
"\\<(?:([\\sa-zA-Z_0-9\\." +
Pattern.quote("+-*/^()[]!" + RenameWand.SUBSTRING_RANGE_CHAR + RenameWand.SUBSTRING_DELIMITER_CHAR) +
"]+)" + Pattern.quote(RenameWand.SPECIAL_CONSTRUCT_SEPARATOR_CHAR + "") + ")?(" +
Pattern.quote(RenameWand.INTEGER_FILTER_INDICATOR_CHAR + "") +
")?([\\sa-zA-Z_0-9\\." +
Pattern.quote("+-*/^()[]!" + RenameWand.SUBSTRING_RANGE_CHAR + RenameWand.SUBSTRING_DELIMITER_CHAR) +
"]+)\\>");
/**
* constant: regex pattern for special construct "<length|expr>" in target pattern string.
* Match groups: (1,"length"), (2,"expr")
*/
private static final Pattern TARGET_SPECIAL_CONSTRUCT_PATTERN = Pattern.compile(
"\\<(?:([\\sa-zA-Z_0-9\\." +
Pattern.quote("+-*/^()[]#!@" + RenameWand.SUBSTRING_RANGE_CHAR + RenameWand.SUBSTRING_DELIMITER_CHAR) +
"]+)" + Pattern.quote(RenameWand.SPECIAL_CONSTRUCT_SEPARATOR_CHAR + "") +
")?([\\sa-zA-Z_0-9\\." +
Pattern.quote("+-*/^()[]#!@" + RenameWand.SUBSTRING_RANGE_CHAR + RenameWand.SUBSTRING_DELIMITER_CHAR) +
"]+)\\>");
/**
* constant: regex pattern for a numeric pattern, e.g. -123.45
* Match groups: (1,"+" or "-"), (2,"123.45")
*/
private static final Pattern NUMERIC_PATTERN = Pattern.compile(
"([\\+\\-]?)([0-9]*(?:\\.[0-9]*)?)");
/** constant: regex pattern for a positive integer pattern, e.g. 42 */
private static final Pattern POSITIVE_INTEGER_PATTERN = Pattern.compile(
"\\+?[0-9]+");
/** operator precedence table for stack evaluation */
private static final Map<String,Integer> OPERATOR_PRECEDENCE = new TreeMap<String,Integer>();
/** singular noun for file/directory */
private static String SINGULAR_NOUN;
/** plural noun for files/directories */
private static String PLURAL_NOUN;
/** standard output */
static PrintWriter stdout = null;
/** standard error */
static PrintWriter stderr = null;
/** true if this is a Windows OS, false otherwise */
private static boolean isWindowsOperatingSystem;
/** current directory (absolute and canonical pathname) */
static File currentDirectory;
/** full pathname of the current directory (includes trailing separator) */
static String currentDirectoryFullPathname;
/** length of the full pathname of the current directory */
static int currentDirectoryFullPathnameLength;
/** register names mapping (register name ---> capture group index) */
static final Map<String,Integer> registerNames = new TreeMap<String,Integer>();
/** number of capture groups in source regex pattern */
private static int numCaptureGroups;
/** true if the source pattern string is reusable for different files/directories; false otherwise */
private static boolean sourcePatternIsReusable = false;
/** subdirectory counter */
private static int numDirs = 0;
/*********************
* RENAME PARAMETERS *
*********************/
/** parameter: simulate only; do not actually rename files/directories (default = false) */
private static boolean simulateOnly = false;
/** parameter: ignore warnings; do not pause (default = false) */
private static boolean ignoreWarnings = false;
/** parameter: recurse into subdirectories (default = false) */
private static boolean recurseIntoSubdirectories = false;
/** parameter: automatically rename files/directories without prompting (default = false) */
private static boolean automaticRename = false;
/** parameter: match relative pathname, not just the name, of the files/directories (default = false) */
private static boolean matchRelativePathname = false;
/** parameter: match lower case name of the files/directories (default = false) */
private static boolean matchLowerCase = false;
/** parameter: true if renaming directories; false if renaming files (default = false) */
private static boolean renameDirectories = false;
/** parameter: default action on rename operation error (default = '\0') */
private static char defaultActionOnRenameOperationError = '\0';
/** parameter: source pattern string */
private static String sourcePatternString;
/** parameter: target pattern string */
private static String targetPatternString;
/*********************
* REPORT STATISTICS *
*********************/
/** statistic: number of warnings encountered */
private static int reportNumWarnings = 0;
/**
* Main entry point for the RenameWand program.
*
* @param args
* Command-line argument strings
*/
public static void main(
final String[] args)
{
/* initialize standard output and error streams */
final Console console = System.console();
if (console == null)
{
RenameWand.stdout = new PrintWriter(System.out);
RenameWand.stderr = new PrintWriter(System.err);
}
else
{
RenameWand.stdout = console.writer();
RenameWand.stderr = console.writer();
}
RenameWand.stdout.print("\n" + RenameWand.PROGRAM_TITLE);
RenameWand.stdout.flush();
/* exit status code to be reported to the OS when exiting (default = 0) */
int exitCode = 0;
try
{
/* determine if this is a Windows OS */
RenameWand.isWindowsOperatingSystem = System.getProperty("os.name").toUpperCase(Locale.ENGLISH).contains("WINDOWS") &&
(File.separatorChar == '\\');
/* initialize operator precedence table for stack evaluation */
RenameWand.OPERATOR_PRECEDENCE.put("#", 7);
RenameWand.OPERATOR_PRECEDENCE.put("#!", 7);
RenameWand.OPERATOR_PRECEDENCE.put("##", 7);
RenameWand.OPERATOR_PRECEDENCE.put("##!", 7);
RenameWand.OPERATOR_PRECEDENCE.put("@", 7);
RenameWand.OPERATOR_PRECEDENCE.put("@!", 7);
RenameWand.OPERATOR_PRECEDENCE.put("@@", 7);
RenameWand.OPERATOR_PRECEDENCE.put("@@!", 7);
RenameWand.OPERATOR_PRECEDENCE.put("^", 6);
RenameWand.OPERATOR_PRECEDENCE.put("~", 5); // unary minus (negative) sign
RenameWand.OPERATOR_PRECEDENCE.put("*", 4);
RenameWand.OPERATOR_PRECEDENCE.put("/", 4);
RenameWand.OPERATOR_PRECEDENCE.put("+", 3);
RenameWand.OPERATOR_PRECEDENCE.put("-", 3);
RenameWand.OPERATOR_PRECEDENCE.put(RenameWand.SUBSTRING_RANGE_CHAR + "", 2);
RenameWand.OPERATOR_PRECEDENCE.put(RenameWand.SUBSTRING_DELIMITER_CHAR + "", 1);
/* process command-line arguments and configure rename parameters */
processArguments(args);
/* nouns for file/directory */
RenameWand.SINGULAR_NOUN = RenameWand.renameDirectories ? "directory" : "file";
RenameWand.PLURAL_NOUN = RenameWand.renameDirectories ? "directories" : "files";
RenameWand.stdout.print("\n\nSource pattern: \"" + RenameWand.sourcePatternString +
"\"\nTarget pattern: \"" + RenameWand.targetPatternString +
"\"\n\nGetting all candidate " + RenameWand.SINGULAR_NOUN +
" names in the current directory" +
(RenameWand.recurseIntoSubdirectories ? " recursively..." : "..."));
RenameWand.stdout.flush();
/* files/directories to be renamed */
List<FileUnit> files = null;
/* get match candidates */
files = getMatchCandidates();
final int numMatchCandidates = files.size();
if (numMatchCandidates == 0)
{
RenameWand.stdout.print("\nNo candidate " + RenameWand.SINGULAR_NOUN + " names to match.");
}
else
{
/* perform source pattern matching on candidate file/directory names */
RenameWand.stdout.print("\nPerforming source pattern matching on " + numMatchCandidates +
" candidate " + RenameWand.SINGULAR_NOUN + " " +
((numMatchCandidates == 1) ? "name" : "names") + "...");
RenameWand.stdout.flush();
files = performSourcePatternMatching(files);
final int numMatched = files.size();
RenameWand.stdout.print("\n" + numMatched + " out of " + numMatchCandidates +
" " + RenameWand.SINGULAR_NOUN + " " +
((numMatched == 1) ? "name" : "names") + " matched.");
RenameWand.stdout.flush();
if (numMatched == 0)
{
RenameWand.stdout.print("\nNo " + RenameWand.PLURAL_NOUN + " to rename.");
}
else
{
/* evaluate target file/directory names */
RenameWand.stdout.print("\nDetermining target " + RenameWand.SINGULAR_NOUN + " " +
((numMatched == 1) ? "name" : "names") + " and renaming sequence...");
RenameWand.stdout.flush();
evaluateTargetPattern(files.toArray(new FileUnit[numMatched]));
/* determine renaming sequence and find clashes, bad names, etc. */
final List<RenameFilePair> renameOperations = getRenameOperations(files);
final int numRenameOperations = renameOperations.size();
/* prompt user before renaming */
final boolean proceedToRename = promptUserOnRename(files, numRenameOperations);
/* perform rename operations */
final int numRenameOperationsPerformed = proceedToRename ? performRenameOperations(renameOperations) : 0;
if (!RenameWand.simulateOnly && proceedToRename)
{
RenameWand.stdout.print("\n\n" + numRenameOperationsPerformed + " out of " +
numRenameOperations + " " + RenameWand.SINGULAR_NOUN + " rename " +
((numRenameOperations == 1) ? "operation" : "operations") + " performed.");
RenameWand.stdout.flush();
}
/* report statistics */
final StringBuilder report = new StringBuilder();
report.append("\n\n" + (RenameWand.renameDirectories ? "DIRECTORY" : "FILE") + " RENAME REPORT");
if (RenameWand.reportNumWarnings > 0)
{
report.append("\n " + RenameWand.reportNumWarnings + " " +
((RenameWand.reportNumWarnings == 1) ? "warning" : "warnings") + " encountered.");
}
report.append("\n No. of candidate " + RenameWand.SINGULAR_NOUN + " names to match : " + numMatchCandidates +
"\n No. of " + RenameWand.SINGULAR_NOUN + " names matched : " + numMatched +
"\n No. of " + RenameWand.SINGULAR_NOUN + " rename operations required: " + numRenameOperations);
if (!RenameWand.simulateOnly && (numRenameOperations > 0))
{
report.append("\n No. of successful " + RenameWand.SINGULAR_NOUN +
" rename operations performed: " + numRenameOperationsPerformed);
}
RenameWand.stdout.print(report.toString());
}
}
RenameWand.stdout.print("\n\nRenameWand is done!\n\n");
}
catch (TerminatingException e)
{
/* terminating exception thrown; proceed to abort program */
/* (this should be the only place where a TerminatingException is caught) */
exitCode = e.getExitCode();
if (exitCode != 0)
{
/* abnormal termination; print error message */
RenameWand.stderr.print("\n\nERROR: " + e.getMessage() + "\n");
RenameWand.stdout.print("\nRenameWand aborted.\n\n");
}
}
catch (Exception e)
{
/* catch all other exceptions; proceed to abort program */
RenameWand.stderr.print("\n\nERROR: An unexpected error has occurred:\n" +
getExceptionInformation(e) + "\n");
exitCode = 1;
RenameWand.stdout.print("\nRenameWand aborted.\n\n");
}
finally
{
/* perform clean-up before exiting */
RenameWand.stderr.flush();
RenameWand.stdout.flush();
}
System.exit(exitCode);
}
/**
* Process command-line arguments.
*
* @param args
* Command-line argument strings
*/
private static void processArguments(
final String[] args)
{
final String howHelp = "\nTo display help, run RenameWand without any command-line arguments.";
/* print usage documentation, if no arguments */
if (args.length == 0)
{
printUsage();
throw new TerminatingException(null, 0);
}
if (args.length < 2)
throw new TerminatingException("Insufficient arguments:\nThe source and target pattern strings must be specified." + howHelp);
/* source and target pattern strings */
RenameWand.sourcePatternString = args[args.length - 2];
RenameWand.targetPatternString = args[args.length - 1];
/* check for illegal characters in pattern strings */
if (RenameWand.sourcePatternString.contains("\0"))
throw new TerminatingException("Illegal null-character found in source pattern string.");
if (RenameWand.targetPatternString.contains("\0"))
throw new TerminatingException("Illegal null-character found in target pattern string.");
/* default action on rename operation error */
int skipOnRenameOperationError = 0;
int undoAllOnRenameOperationError = 0;
int abortOnRenameOperationError = 0;
/* process command-line switches */
for (int i = 0; i < args.length - 2; i++)
{
final String sw = args[i];
if ("--recurse".equals(sw) || "-r".equals(sw))
{
/* recurse into subdirectories */
RenameWand.recurseIntoSubdirectories = true;
}
else if ("--dirs".equals(sw) || "-d".equals(sw))
{
/* rename directories instead of files */
RenameWand.renameDirectories = true;
}
else if ("--path".equals(sw) || "-p".equals(sw))
{
/* match relative pathname, not just the name, of the files/directories */
RenameWand.matchRelativePathname = true;
}
else if ("--lower".equals(sw) || "-l".equals(sw))
{
/* match lower case name of the files/directories */
RenameWand.matchLowerCase = true;
}
else if ("--yes".equals(sw) || "-y".equals(sw))
{
/* automatically rename files/directories without prompting */
RenameWand.automaticRename = true;
}
else if ("--simulate".equals(sw) || "-s".equals(sw))
{
/* simulate only; do not actually rename files/directories */
RenameWand.simulateOnly = true;
RenameWand.ignoreWarnings = true;
}
else if ("--ignorewarnings".equals(sw) || "-i".equals(sw))
{
/* ignore warnings; do not pause */
RenameWand.ignoreWarnings = true;
}
else if ("--skip".equals(sw))
{
/* skip on rename operation error */
skipOnRenameOperationError = 1;
}
else if ("--undoall".equals(sw))
{
/* undo all on rename operation error */
undoAllOnRenameOperationError = 1;
}
else if ("--abort".equals(sw))
{
/* abort on rename operation error */
abortOnRenameOperationError = 1;
}
else
{
/* invalid switch */
throw new TerminatingException("\"" + sw + "\" is not a valid switch." + howHelp);
}
}
if (RenameWand.simulateOnly && RenameWand.automaticRename)
throw new TerminatingException("Switches --simulate and --yes cannot be used together." + howHelp);
if (skipOnRenameOperationError + undoAllOnRenameOperationError + abortOnRenameOperationError > 1)
throw new TerminatingException("Only one of the three switches --skip, --undoall, and --abort may be specified." + howHelp);
if (RenameWand.simulateOnly && (skipOnRenameOperationError + undoAllOnRenameOperationError + abortOnRenameOperationError > 0))
throw new TerminatingException("Switches --skip, --undoall, and --abort cannot be used together with --simulate." + howHelp);
/* default action on rename operation error */
if (skipOnRenameOperationError > 0)
RenameWand.defaultActionOnRenameOperationError = 'S';
if (undoAllOnRenameOperationError > 0)
RenameWand.defaultActionOnRenameOperationError = 'U';
if (abortOnRenameOperationError > 0)
RenameWand.defaultActionOnRenameOperationError = 'A';
}
/**
* Scan current directory to get candidate files/directories for matching.
*
* @return
* Candidate files/directories for matching
*/
private static List<FileUnit> getMatchCandidates()
{
/* get absolute canonical path of the current directory */
RenameWand.currentDirectory = new File("");
try
{
RenameWand.currentDirectory = RenameWand.currentDirectory.getCanonicalFile();
}
catch (Exception e)
{
throw new TerminatingException("Failed to get full pathname of the current directory \"" +
RenameWand.currentDirectory.getPath() + "\":\n" + getExceptionInformation(e));
}
RenameWand.currentDirectoryFullPathname = RenameWand.currentDirectory.getPath();
/* include trailing separator */
if (!RenameWand.currentDirectoryFullPathname.endsWith(File.separator))
RenameWand.currentDirectoryFullPathname += File.separator;
RenameWand.currentDirectoryFullPathnameLength = RenameWand.currentDirectoryFullPathname.length();
/* return value: match candidate files/directories */
final List<FileUnit> matchCandidates = new ArrayList<FileUnit>();
/* stack containing the subdirectories to be scanned */
final Deque<File> subdirectories = new ArrayDeque<File>();
subdirectories.push(RenameWand.currentDirectory);
/* reset number of subdirectories scanned */
RenameWand.numDirs = 0;
/* perform a DFS scanning of the subdirectories */
while (!subdirectories.isEmpty())
{
RenameWand.numDirs++;
/* get a directory to be scanned */
final File dir = subdirectories.pop();
final File[] listFiles = dir.listFiles();
if (listFiles == null)
{
final String path = dir.getPath();
reportWarning("Failed to get contents of directory \"" + path +
(path.endsWith(File.separator) ? "" : File.separator) +
"\".\nThis directory will be ignored.");
}
else
{
/* subdirectories under this directory */
final List<File> subdirs = new ArrayList<File>();
for (File f : listFiles)
{
final boolean isDirectory = f.isDirectory();
if (RenameWand.renameDirectories == isDirectory)
{
final FileUnit u = new FileUnit();
u.source = f;
u.parentDirId = RenameWand.numDirs;
matchCandidates.add(u);
}
if (isDirectory)
subdirs.add(f);
}
if (RenameWand.recurseIntoSubdirectories)
{
for (int i = subdirs.size() - 1; i >= 0; i--)
subdirectories.push(subdirs.get(i));
}
}
}
return matchCandidates;
}
/**
* Perform source pattern matching against the names of the candidate
* files/directories, and return files/directories that match.
*
* @param matchCandidates
* Candidate files/directories
* @return
* Files/directories with names that match the source pattern
*/
private static List<FileUnit> performSourcePatternMatching(
final List<FileUnit> matchCandidates)
{
/* return value: files/directories with names that match the source pattern */
final List<FileUnit> matched = new ArrayList<FileUnit>();
/* regex pattern used for matching file/directory names */
Pattern sourcePattern = null;
/* is the regex pattern matcher reusable for different files/directories? */
RenameWand.sourcePatternIsReusable = false;
/* match each candidate file or directory */
for (FileUnit u : matchCandidates)
{
if (!RenameWand.sourcePatternIsReusable)
sourcePattern = getFileSourcePattern(u);
/* check if source pattern is successfully generated */
if (sourcePattern != null)
{
/* name string to be matched */
String name = null;
if (RenameWand.matchRelativePathname)
{
name = u.source.getPath();
if (name.startsWith(RenameWand.currentDirectoryFullPathname))
name = name.substring(RenameWand.currentDirectoryFullPathnameLength);
}
else
{
name = u.source.getName();
}
/* trim off trailing separator */
while (name.endsWith(File.separator))
name = name.substring(0, name.length() - File.separator.length());
if (RenameWand.matchLowerCase)
name = name.toLowerCase(Locale.ENGLISH);
/* regex pattern matcher */
final Matcher sourceMatcher = sourcePattern.matcher(name);
if (sourceMatcher.matches())
{
/* add capture group values to FileUnit's registerValues, and */
/* add this file/directory to our list of successful matches */
u.registerValues = new String[RenameWand.numCaptureGroups + 1]; // add index offset 1
for (int i = 1; i <= RenameWand.numCaptureGroups; i++)
u.registerValues[i] = sourceMatcher.group(i);
matched.add(u);
}
}
}
return matched;
}
/**
* Generate source regex pattern corresponding to the given file/directory.
*
* @param u
* File/directory for which to generate source regex pattern
* @return
* Source regex pattern corresponding to the given file/directory;
* null if regex pattern cannot be generated.
*/
private static Pattern getFileSourcePattern(
final FileUnit u)
{
/* reset register names and capture group counter */
RenameWand.registerNames.clear();
int captureGroupIndex = 0;
/* assume that source pattern is reusable */
RenameWand.sourcePatternIsReusable = true;
/* Stack to keep track of the parser mode: */
/* "--" : Base mode (first on the stack) */
/* "[]" : Square brackets mode "[...]" */
/* "{}" : Curly braces mode "{...}" */
final Deque<String> parserMode = new ArrayDeque<String>();
parserMode.push("--"); // base mode
final int sourcePatternStringLength = RenameWand.sourcePatternString.length();
int index = 0; // index in sourcePatternString
/* regex pattern equivalent to sourcePatternString */
final StringBuilder sourceRegex = new StringBuilder();
/* parse each character of the source pattern string */
while (index < sourcePatternStringLength)
{
char c = RenameWand.sourcePatternString.charAt(index++);
if (c == '\\')
{
/***********************
* (1) ESCAPE SEQUENCE *
***********************/
if (index == sourcePatternStringLength)
{
/* no characters left, so treat '\' as literal char */
sourceRegex.append(Pattern.quote("\\"));
}
else
{
/* read next character */
c = RenameWand.sourcePatternString.charAt(index);
final String s = c + "";