de.unkrig.zz.patch.AntTask Maven / Gradle / Ivy
/*
* de.unkrig.patch - An enhanced version of the UNIX PATCH utility
*
* Copyright (c) 2011, Arno Unkrig
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
* following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the
* following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
* following disclaimer in the documentation and/or other materials provided with the distribution.
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package de.unkrig.zz.patch;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectComponent;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.FileResource;
import de.unkrig.commons.file.contentstransformation.ContentsTransformer;
import de.unkrig.commons.file.filetransformation.FileTransformer;
import de.unkrig.commons.lang.protocol.Mappings;
import de.unkrig.commons.lang.protocol.Predicate;
import de.unkrig.commons.lang.protocol.PredicateUtil;
import de.unkrig.commons.lang.protocol.RunnableWhichThrows;
import de.unkrig.commons.nullanalysis.Nullable;
import de.unkrig.commons.text.AbstractPrinter;
import de.unkrig.commons.text.Printers;
import de.unkrig.commons.text.StringStream.UnexpectedElementException;
import de.unkrig.commons.text.expression.EvaluationException;
import de.unkrig.commons.text.expression.Expression;
import de.unkrig.commons.text.expression.ExpressionEvaluator;
import de.unkrig.commons.text.expression.ExpressionUtil;
import de.unkrig.commons.text.parser.ParseException;
import de.unkrig.commons.text.pattern.Glob;
import de.unkrig.commons.text.pattern.Pattern2;
import de.unkrig.zz.patch.SubstitutionContentsTransformer.Mode;
import de.unkrig.zz.patch.diff.DiffParser.Hunk;
/**
* Adds, removes, renames and/or changes the contents of files and archives.
*
* Implements the following features:
*
*
* -
* Transforms regular files, directory trees, compressed files and entries in archive files, and also in nested
* archives
*
* - Handles patch files in NORMAL, CONTEXT and UNIFIED diff format
* - Replaces the contents of files from with that of an "update file"
* - Does search-and-replace within files (SED like)
* - Transform in-place as well as out-of-place
* - Optionally keeps copies of the original files
* - Adds, removes and renames files, directories and archive entries
*
*
* To use this task, add this to your ANT build script:
*
* {@code
* }
*/
public
class AntTask extends Task {
private final Patch patch = new Patch();
private FileTransformer.Mode mode = FileTransformer.Mode.TRANSFORM;
private final List resourceCollections = new ArrayList();
@Nullable private File file;
@Nullable private File tofile;
@Nullable private File todir;
/**
* Configures how the files are processed.
*
* - {@code TRANSFORM}
* -
* Execute the operation without previously checking if it actually changes any files.
*
* - {@code CHECK}
* -
* Execute the operation, but do not create or modify any files, and fail iff the operation does not produce
* an identical result. Since
is typically much cheaper in this mode than in mode TRANSFORM, it may
* be efficient to execute in this mode first to check whether the transformation would modify and
* files, before executing it in TRANSFORM mode, particularly if you expect few or no modifications.
* -
*
- {@code CHECK_AND_TRANSFORM}
* -
* For in-place transformations: Before executing the actual transformation, verify that it will actually
* modify any files. Since checking whether a transformation would actually change any files is typically much
* cheaper than the executing the actual transformation, this mode may be more efficient than TRANSFORM mode,
* particularly if you expect few or no modifications.
*
*
*
* @ant.defaultValue TRANSFORM
*/
public void
setMode(FileTransformer.Mode mode) { this.mode = mode; }
/**
* Whether to keep backup copies of files/entries that are modified or removed, renamed to {@code
* ".}file-name{@code .orig"}.
*/
public void
setKeepOriginals(boolean value) { this.patch.setKeepOriginals(value); }
/**
* Configures a file to be transformed; either in-place, or, iff {@code tofile=...} or {@code todir=...} is
* configured, out-of-place.
*/
public void
setFile(File file) { this.file = file; }
/**
* Configures the output file for the input file specified through {@code file=...}. The default is to patch the
* input file in-place.
*/
public void
setTofile(File file) { this.tofile = file; }
/**
* Configures the directory for the output file created from {@code file=...} and/or the resource collection
* subelements.
*/
public void
setTodir(File existingDir) { this.todir = existingDir; }
/**
* Look into compressed and archive contents if the format and the path match the given glob.
*
* Supported archive formats are: [cpio, zip, dump, jar, tar, ar, arj, 7z].
*
*
* Supported compression formats are: [snappy-raw, bzip2, gz, snappy-framed, pack200, xz, z, lzma].
*
*
* The default is too look into any recognized archive or compressed contents.
*
*
* Example:
*
*
* {@code lookInto="zip:**,tar:**,gz:**"}
*
*
* @ant.valueExplanation format-glob:path-glob
*/
public void
setLookInto(String value) {
this.patch.setLookIntoFormat(Glob.compile(value, Pattern2.WILDCARD | Glob.INCLUDES_EXCLUDES));
}
/**
* Adds another set of resources ({@code }, {@code }, ...) that will be patched.
*/
public void
addConfigured(ResourceCollection value) { this.resourceCollections.add(value); }
/**
* Configures that the contents of files/entries that match the {@code name} glob pattern be replaced with the
* contents of the given "update file".
*/
public void
addConfiguredUpdate(Element_path element) {
if (element.path == PredicateUtil.always()) {
throw new BuildException("'name==' must be configured");
}
this.patch.addContentsTransformation(element.path, new UpdateContentsTransformer(element.path));
}
/**
* Configures that lines that match the {@link SubstituteElement#setRegex(String)} within files/entries that match
* the {@link SubstituteElement#setPath(String)} glob pattern be replaced with the {@link
* SubstituteElement#setReplacement(String)} string.
*
* Alternatively, the regex and the replacement can be configured with {@link
* SubstituteElement#addConfiguredRegex(AntTask.TextElement)} and {@link
* SubstituteElement#addConfiguredReplacement(AntTask.TextElement)} subelements; one advantage of subelements is
* that they can contain {@code } sections where you don't have to SGML-escape special characters.
*
*
* You are not limited to line-wise pattern matching, but be careful with using greedy quantifiers, because these
* may require that the entire contents of each file needs to be loaded into memory.
*
*/
public void
addConfiguredSubstitute(final SubstituteElement element) throws ParseException {
this.addContentsTransformation(
element.path,
new SubstitutionContentsTransformer(
element.inputCharset,
element.outputCharset,
Pattern.compile(element.getRegex(), Pattern.MULTILINE),
element.getMode(),
element.getReplacement(),
AntTask.expressionToSubstitutionCondition(element.condition)
)
);
}
private static SubstitutionContentsTransformer.Condition
expressionToSubstitutionCondition(final Expression condition) {
if (condition == Expression.TRUE) return SubstitutionContentsTransformer.Condition.ALWAYS;
if (condition == Expression.FALSE) return SubstitutionContentsTransformer.Condition.NEVER;
return new SubstitutionContentsTransformer.Condition() {
@Override public boolean
evaluate(String path, CharSequence match, int occurrence) {
try {
return ExpressionEvaluator.toBoolean(
condition.evaluate(Mappings.mapping(
"path", path, // SUPPRESS CHECKSTYLE Wrap:2
"match", match,
"occurrence", occurrence
))
);
} catch (EvaluationException ee) {
throw new RuntimeException(ee);
}
}
@Override public String
toString() { return condition.toString(); }
};
}
/**
* Configures a substitution, i.e. the transformation of contents by finding pattern matches and replacing them
* with a replacement string.
*/
public static
class SubstituteElement extends Element_path {
private Charset inputCharset = Charset.defaultCharset();
private Charset outputCharset = Charset.defaultCharset();
private Expression condition = Expression.TRUE;
@Nullable private String regex;
private Mode replacementMode = Mode.REPLACEMENT_STRING;
@Nullable private String replacement;
/**
* The encoding of the transformation input; defaults to the platform default encoding.
*/
public void setInputEncoding(String charset) { this.inputCharset = Charset.forName(charset); }
/**
* The encoding of the transformation output; defaults to the platform default encoding.
*/
public void setOutputEncoding(String charset) { this.outputCharset = Charset.forName(charset); }
/**
* The regular expression the defines a match.
*
* For the precise description of the supported regular-expression constructs, see here.
*
*/
public void
setRegex(String regex) {
if (this.regex != null) {
throw new BuildException("Only one of 'regex=...' and '' must be configured");
}
this.regex = regex;
}
/**
* The regular expression the defines a match.
*
* For the precise description of the supported regular-expression constructs, see here.
*
*/
public void
addConfiguredRegex(TextElement subelement) {
if (this.regex != null) {
throw new BuildException("Only one of 'regex=...' and '' must be configured");
}
this.regex = subelement.text;
}
/** Getter for the mandatory 'regex' attribute or subelement. */
public String
getRegex() {
String regex = this.regex;
if (regex == null) {
throw new BuildException("A 'regex=\"...\"' attribute or a '' subelement must be configured");
}
return regex;
}
/**
* Determines how the {@link #setReplacement(String)} is processed.
*
* - {@code REPLACEMENT_STRING}
* -
* A JRE replacement string
*
* - {@code CONSTANT}
* - A constant string - no character (esp. dollar, backslash) has a special meaning
* - {@code EXPRESSION}
* -
* A Java-like expression
*
*
*
* @ant.defaultValue REPLACEMENT_STRING
*/
public void
setReplacementMode(SubstitutionContentsTransformer.Mode replacementMode) {
this.replacementMode = replacementMode;
}
/**
* The "replacement" that determines how each match is replaced.
*
* For the precise description of the format, see here.
*
*/
public void
setReplacement(String replacement) {
if (this.replacement != null) {
throw new BuildException("Only one of 'replacement=...' and '' must be configured");
}
this.replacement = replacement;
}
/**
* The "replacement string" that determines how each match is replaced.
*
* For the precise description of the format, see here.
*
*/
public void
addConfiguredReplacement(TextElement subelement) {
if (this.replacement != null) {
throw new BuildException("Only one of 'replacement=...' and '' must be configured");
}
this.replacement = subelement.text;
}
public SubstitutionContentsTransformer.Mode
getMode() { return this.replacementMode; }
/**
* Getter for the mandatory {@code replacement=...} attribute or {@code } subelement.
*/
public String
getReplacement() {
String replacement = this.replacement;
if (replacement == null) {
throw new BuildException(
"A 'replacement=\"...\"' attribute or a '' subelement must be configured"
);
}
return replacement;
}
/**
* Configures a condition that must evaluate to {@code true} before each occurrence is replaced.
*
* The following variables are available in the expression:
*
*
* - {@code path}
* -
* The path currently being patched.
*
* - {@code match}
* -
* The text of the match.
*
* - {@code occurrence}
* -
* The index of the occurrence within the document, starting at zero.
*
*
*/
public void
setCondition(String expression) throws ParseException {
this.condition = (
new ExpressionEvaluator("path", "match", "occurrence").parse(expression)
);
}
}
/**
* Configures a transformation by applying a DIFF document.
*
* A DIFF document generally contains differentials for one or more files. Each differential comprises a sequence
* of "hunks". Traditional format, context format and unified format are supported.
*
*
* If the DIFF document describes more than on differential, then all but the first differential are ignored.
* The file name information in the differential is also ignored.
*
*
* @throws UnexpectedElementException The patch file does not contain a valid DIFF document
*/
public void
addConfiguredPatch(final PatchElement element)
throws IOException, UnexpectedElementException {
File patchFile = element.patchFile;
if (patchFile == null) throw new BuildException("Attribute 'patchFile' must be set");
this.addContentsTransformation(
element.path,
new PatchContentsTransformer(
element.inputCharset,
element.outputCharset,
patchFile,
element.patchFileCharset,
new PatchContentsTransformer.Condition() {
@Override public boolean
evaluate(
String path,
List hunks,
int hunkIndex,
Hunk hunk,
int lineNumber
) {
try {
return ExpressionEvaluator.toBoolean(
element.condition.evaluate(Mappings.mapping(
"path", path, // SUPPRESS CHECKSTYLE Wrap:4
"hunks", hunks,
"hunkIndex", hunkIndex,
"lineNumber", lineNumber
))
);
} catch (EvaluationException ee) {
throw new RuntimeException(ee);
}
}
}
)
);
}
/**
* Configures a patch, i.e. the transformation of contents by applying a "DIFF document" to the input.
*/
public static
class PatchElement extends Element_path { // SUPPRESS CHECKSTYLE TypeName
private Charset inputCharset = Charset.defaultCharset();
private Charset outputCharset = Charset.defaultCharset();
@Nullable private File patchFile;
private Charset patchFileCharset = Charset.defaultCharset();
private Expression condition = ExpressionUtil.constantExpression(Boolean.TRUE);
/**
* The encoding of the transformation input; defaults to the platform default encoding.
*/
public void setInputEncoding(String value) { this.inputCharset = Charset.forName(value); }
/**
* The encoding of the transformation output; defaults to the platform default encoding.
*/
public void setOutputEncoding(String value) { this.outputCharset = Charset.forName(value); }
/**
* The file that contains the DIFF document.
*/
public void setPatchFile(File patchFile) { this.patchFile = patchFile; }
/**
* The encoding of the patch file; defaults to the platform default encoding.
*/
public void setPatchFileEncoding(String charset) { this.patchFileCharset = Charset.forName(charset); }
/**
* Configures a condition that must evaluate to {@code true} before each DIFF hunk is applied.
*
* The following variables are available in the expression:
*
*
* - {@code path}
* -
* The path currently being patched.
*
* - {@code hunks}
* -
* The hunks being applied.
*
* - {@code hunkIndex}
* -
* The index of the current hunk.
*
* - {@code hunk}
* -
* The current hunk.
*
* - {@code lineNumber}
* -
* The line number where the hunk starts.
*
*
*/
public void
setCondition(String expression) throws ParseException {
this.condition = (
new ExpressionEvaluator("path", "hunks", "hunkIndex", "hunk", "lineNumber").parse(expression)
);
}
}
/**
* Configures that files/entries that match the {@code name} be deleted/removed.
*/
public void
addConfiguredRemove(Element_path element) { this.patch.addRemoval(element.path); }
/**
* Configures that files/entries that match the {@code name} be renamed.
*/
public void
addConfiguredRename(Element_path2 element) { this.patch.addRenaming(element.path); }
/**
* Configures that an entry be added to all archives that match the path pattern.
*/
public void
addConfiguredAdd(AddElement element) {
this.patch.addAddition(element.path, element.getEntryName(), element.getContents());
}
/**
* Configures an "add" operation, i.e. an entry that is added to an archive.
*/
public static
class AddElement extends Element_path {
@Nullable private String entryName;
@Nullable private File contents;
/**
* The name of the archive entry to add (may contain slashes).
*/
public void
setEntryName(String entryName) { this.entryName = entryName; }
/**
* @return The value of of the attribute '{@code entryName="..."}'
*/
public String
getEntryName() {
String entryName = this.entryName;
if (entryName == null) {
throw new BuildException("Attribute 'entryName=\"...\"' must be set");
}
return entryName;
}
/**
* The file that contains the contents for the new archive entry.
*/
public void
setContents(File contentsFile) { this.contents = contentsFile; }
/**
* @return The non-{@code null} value of the '{@code contents="..."}' attribute
*/
public File
getContents() {
File contents = this.contents;
if (contents == null) throw new BuildException("Attribute 'contents=\"...\"' must be set");
return contents;
}
}
/**
* A predicate that determines whether a path is applicable by matching it with a glob.
*/
public static
class Element_path extends ProjectComponent { // SUPPRESS CHECKSTYLE TypeName
protected int flags = Pattern2.WILDCARD | Glob.INCLUDES_EXCLUDES;
/**
* The glob that specifies the applicable pathes.
*/
Glob path = Glob.ANY;
/**
* @deprecated Use {@link #setPath(String)} instead
*/
@Deprecated public void
setName(String path) { this.setPath(path); }
/**
* The glob to match the pathes against.
*/
public void
setPath(String glob) { this.path = Glob.compile(glob, this.flags); }
}
/**
* A glob that implements renaming of a path.
*/
public static
class Element_path2 extends Element_path { // SUPPRESS CHECKSTYLE TypeName
{ this.flags |= Glob.REPLACEMENT; }
}
/***/
public static
class TextElement extends ProjectComponent {
private String text = "";
/** See ANT documentation. */
public void
addText(String text) { this.text += this.getProject().replaceProperties(text).trim(); }
}
private void
addContentsTransformation(Predicate pathPredicate, ContentsTransformer delegate) {
this.patch.addContentsTransformation(pathPredicate, delegate);
}
@Override public void
execute() throws BuildException {
try {
this.execute2();
} catch (BuildException be) {
throw be;
} catch (Exception e) {
throw new BuildException(e);
}
}
private void
execute2() throws Exception {
AbstractPrinter printer = new AbstractPrinter() {
@Override public void warn(@Nullable String message) { AntTask.this.log(message, Project.MSG_WARN); }
@Override public void verbose(@Nullable String message) { AntTask.this.log(message, Project.MSG_VERBOSE); }
@Override public void info(@Nullable String message) { AntTask.this.log(message, Project.MSG_INFO); }
@Override public void error(@Nullable String message) { AntTask.this.log(message, Project.MSG_ERR); }
@Override public void debug(@Nullable String message) { AntTask.this.log(message, Project.MSG_DEBUG); }
};
printer.run(new RunnableWhichThrows() {
@Override public void run() throws Exception { AntTask.this.execute3(); }
});
}
private void
execute3() throws Exception {
FileTransformer fileTransformer = this.patch.fileTransformer(
false, // lookIntoDirectories
true // renameOrRemoveTopLevelFiles
);
// Process 'file="..."' / 'tofile="..."' / 'todir="..."'.
File file = this.file;
if (file != null) {
if (this.tofile != null && this.todir != null) {
throw new BuildException(
"'tofile=\"...\"' and 'todir=\"...\"' must not be configured at the same time"
);
}
File out = (
this.tofile != null ? this.tofile :
this.todir != null ? new File(this.todir, file.getName()) :
file
);
if (out.equals(file)) {
Printers.verbose("Patching ''{0}'' in-place", file);
} else {
Printers.verbose("Patching ''{0}'' to ''{1}''", new Object[] { file, out });
}
fileTransformer.transform(file.getPath(), file, out, this.mode);
} else
if (this.tofile != null) {
throw new BuildException(
"'tofile=\"...\"' must only be configured in conjunction with 'file=\"...\"'"
);
}
// Process resource collections / 'todir="..."'.
for (ResourceCollection rc : this.resourceCollections) {
for (@SuppressWarnings("unchecked") Iterator it = rc.iterator(); it.hasNext();) {
Resource resource = it.next();
if (resource.isFilesystemOnly()) {
FileResource fileResource = (FileResource) resource;
File in = fileResource.getFile();
File out = this.todir != null ? new File(this.todir, resource.getName()) : in;
Printers.verbose("Patching ''{0}'' to ''{1}''", new Object[] { in, out });
fileTransformer.transform(fileResource.getName(), in, out, FileTransformer.Mode.TRANSFORM);
} else {
File out = new File(this.todir, resource.getName());
Printers.verbose("Patching ''{0}'' to ''{1}''", new Object[] { resource, out });
InputStream is = resource.getInputStream();
try {
OutputStream os = new FileOutputStream(out);
try {
this.patch.contentsTransformer().transform(resource.getName(), is, os);
os.close();
} finally {
try { os.close(); } catch (Exception e) {}
}
is.close();
} finally {
try { is.close(); } catch (Exception e) {}
}
}
}
}
}
}