blob: ec0b042c92926b7cbeeb433647de1bceddc92fd7 [file] [log] [blame]
/*
* Copyright (c) 2002, 2018, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code 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
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.FilenameFilter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.lang.annotation.Annotation;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.SoftReference;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.UnsupportedCharsetException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Test framework for running javadoc and performing tests on the resulting output.
*
* <p>
* Tests are typically written as subtypes of JavadocTester, with a main
* method that creates an instance of the test class and calls the runTests()
* method. The runTests() methods calls all the test methods declared in the class,
* and then calls a method to print a summary, and throw an exception if
* any of the test methods reported a failure.
*
* <p>
* Test methods are identified with a @Test annotation. They have no parameters.
* The name of the method is not important, but if you have more than one, it is
* recommended that the names be meaningful and suggestive of the test case
* contained therein.
*
* <p>
* Typically, a test method will invoke javadoc, and then perform various
* checks on the results. The standard checks are:
*
* <dl>
* <dt>checkExitCode
* <dd>Check the exit code returned from javadoc.
* <dt>checkOutput
* <dd>Perform a series of checks on the contents on a file or output stream
* generated by javadoc.
* The checks can be either that a series of strings are found or are not found.
* <dt>checkFiles
* <dd>Perform a series of checks on the files generated by javadoc.
* The checks can be that a series of files are found or are not found.
* </dl>
*
* <pre><code>
* public class MyTester extends JavadocTester {
* public static void main(String... args) throws Exception {
* MyTester tester = new MyTester();
* tester.runTests();
* }
*
* // test methods...
* @Test
* void test() {
* javadoc(<i>args</i>);
* checkExit(Exit.OK);
* checkOutput(<i>file</i>, true,
* <i>strings-to-find</i>);
* checkOutput(<i>file</i>, false,
* <i>strings-to-not-find</i>);
* }
* }
* </code></pre>
*
* <p>
* If javadoc is run more than once in a test method, you can compare the
* results that are generated with the diff method. Since files written by
* javadoc typically contain a timestamp, you may want to use the -notimestamp
* option if you are going to compare the results from two runs of javadoc.
*
* <p>
* If you have many calls of checkOutput that are very similar, you can write
* your own check... method to reduce the amount of duplication. For example,
* if you want to check that many files contain the same string, you could
* write a method that takes a varargs list of files and calls checkOutput
* on each file in turn with the string to be checked.
*
* <p>
* You can also write you own custom check methods, which can use
* readFile to get the contents of a file generated by javadoc,
* and then use pass(...) or fail(...) to report whether the check
* succeeded or not.
*
* <p>
* You can have many separate test methods, each identified with a @Test
* annotation. However, you should <b>not</b> assume they will be called
* in the order declared in your source file. If the order of a series
* of javadoc invocations is important, do that within a single method.
* If the invocations are independent, for better clarity, use separate
* test methods, each with their own set of checks on the results.
*
* @author Doug Kramer
* @author Jamie Ho
* @author Jonathan Gibbons (rewrite)
*/
public abstract class JavadocTester {
public static final String FS = System.getProperty("file.separator");
public static final String PS = System.getProperty("path.separator");
public static final String NL = System.getProperty("line.separator");
public static final Path currDir = Paths.get(".").toAbsolutePath().normalize();
public enum Output {
/** The name of the output stream from javadoc. */
OUT,
/** The name for any output written to System.out. */
STDOUT,
/** The name for any output written to System.err. */
STDERR
}
/** The output directory used in the most recent call of javadoc. */
protected File outputDir;
/** The output charset used in the most recent call of javadoc. */
protected Charset charset = Charset.defaultCharset();
/** The exit code of the most recent call of javadoc. */
private int exitCode;
/** The output generated by javadoc to the various writers and streams. */
private final Map<Output, String> outputMap = new EnumMap<>(Output.class);
/** A cache of file content, to avoid reading files unnecessarily. */
private final Map<File,SoftReference<String>> fileContentCache = new HashMap<>();
/** The charset used for files in the fileContentCache. */
private Charset fileContentCacheCharset = null;
/** Stream used for logging messages. */
protected final PrintStream out = System.out;
/** The directory containing the source code for the test. */
public static final String testSrc = System.getProperty("test.src");
/**
* Get the path for a source file in the test source directory.
* @param path the path of a file or directory in the source directory
* @return the full path of the specified file
*/
public static String testSrc(String path) {
return new File(testSrc, path).getPath();
}
/**
* Alternatives for checking the contents of a directory.
*/
public enum DirectoryCheck {
/**
* Check that the directory is empty.
*/
EMPTY((file, name) -> true),
/**
* Check that the directory does not contain any HTML files,
* such as may have been generated by a prior run of javadoc
* using this directory.
* For now, the check is only performed on the top level directory.
*/
NO_HTML_FILES((file, name) -> name.endsWith(".html")),
/**
* No check is performed on the directory contents.
*/
NONE(null) { @Override void check(File dir) { } };
/** The filter used to detect that files should <i>not</i> be present. */
FilenameFilter filter;
DirectoryCheck(FilenameFilter f) {
filter = f;
}
void check(File dir) {
if (dir.isDirectory()) {
String[] contents = dir.list(filter);
if (contents == null)
throw new Error("cannot list directory: " + dir);
if (contents.length > 0) {
System.err.println("Found extraneous files in dir:" + dir.getAbsolutePath());
for (String x : contents) {
System.err.println(x);
}
throw new Error("directory has unexpected content: " + dir);
}
}
}
}
private DirectoryCheck outputDirectoryCheck = DirectoryCheck.EMPTY;
private boolean automaticCheckLinks = true;
/** The current subtest number. Incremented when checking(...) is called. */
private int numTestsRun = 0;
/** The number of subtests passed. Incremented when passed(...) is called. */
private int numTestsPassed = 0;
/** The current run of javadoc. Incremented when javadoc is called. */
private int javadocRunNum = 0;
/** The current subtest number for this run of javadoc. Incremented when checking(...) is called. */
private int javadocTestNum = 0;
/** Marker annotation for test methods to be invoked by runTests. */
@Retention(RetentionPolicy.RUNTIME)
@interface Test { }
/**
* Run all methods annotated with @Test, followed by printSummary.
* Typically called on a tester object in main()
* @throws Exception if any errors occurred
*/
public void runTests() throws Exception {
runTests(m -> new Object[0]);
}
/**
* Run all methods annotated with @Test, followed by printSummary.
* Typically called on a tester object in main()
* @param f a function which will be used to provide arguments to each
* invoked method
* @throws Exception if any errors occurred
*/
public void runTests(Function<Method, Object[]> f) throws Exception {
for (Method m: getClass().getDeclaredMethods()) {
Annotation a = m.getAnnotation(Test.class);
if (a != null) {
try {
out.println("Running test " + m.getName());
m.invoke(this, f.apply(m));
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
throw (cause instanceof Exception) ? ((Exception) cause) : e;
}
out.println();
}
}
printSummary();
}
/**
* Run javadoc.
* The output directory used by this call and the final exit code
* will be saved for later use.
* To aid the reader, it is recommended that calls to this method
* put each option and the arguments it takes on a separate line.
*
* Example:
* <pre><code>
* javadoc("-d", "out",
* "-sourcepath", testSrc,
* "-notimestamp",
* "pkg1", "pkg2", "pkg3/C.java");
* </code></pre>
*
* @param args the arguments to pass to javadoc
*/
public void javadoc(String... args) {
outputMap.clear();
fileContentCache.clear();
javadocRunNum++;
javadocTestNum = 0; // reset counter for this run of javadoc
if (javadocRunNum == 1) {
out.println("Running javadoc...");
} else {
out.println("Running javadoc (run "+ javadocRunNum + ")...");
}
outputDir = new File(".");
String charsetArg = null;
String docencodingArg = null;
String encodingArg = null;
for (int i = 0; i < args.length - 2; i++) {
switch (args[i]) {
case "-d":
outputDir = new File(args[++i]);
break;
case "-charset":
charsetArg = args[++i];
break;
case "-docencoding":
docencodingArg = args[++i];
break;
case "-encoding":
encodingArg = args[++i];
break;
}
}
// The following replicates HtmlConfiguration.finishOptionSettings0
// and sets up the charset used to read files.
String cs;
if (docencodingArg == null) {
if (charsetArg == null) {
cs = (encodingArg == null) ? "UTF-8" : encodingArg;
} else {
cs = charsetArg;
}
} else {
cs = docencodingArg;
}
try {
charset = Charset.forName(cs);
} catch (UnsupportedCharsetException e) {
charset = Charset.defaultCharset();
}
out.println("args: " + Arrays.toString(args));
// log.setOutDir(outputDir);
outputDirectoryCheck.check(outputDir);
// This is the sole stream used by javadoc
WriterOutput outOut = new WriterOutput();
// These are to catch output to System.out and System.err,
// in case these are used instead of the primary streams
StreamOutput sysOut = new StreamOutput(System.out, System::setOut);
StreamOutput sysErr = new StreamOutput(System.err, System::setErr);
try {
exitCode = jdk.javadoc.internal.tool.Main.execute(args, outOut.pw);
} finally {
outputMap.put(Output.STDOUT, sysOut.close());
outputMap.put(Output.STDERR, sysErr.close());
outputMap.put(Output.OUT, outOut.close());
}
outputMap.forEach((name, text) -> {
if (!text.isEmpty()) {
out.println("javadoc " + name + ":");
out.println(text);
}
});
if (automaticCheckLinks && exitCode == Exit.OK.code && outputDir.exists()) {
checkLinks();
}
}
/**
* Set the kind of check for the initial contents of the output directory
* before javadoc is run.
* The filter should return true for files that should <b>not</b> appear.
* @param c the kind of check to perform
*/
public void setOutputDirectoryCheck(DirectoryCheck c) {
outputDirectoryCheck = c;
}
/**
* Set whether or not to perform an automatic call of checkLinks.
*/
public void setAutomaticCheckLinks(boolean b) {
automaticCheckLinks = b;
}
/**
* The exit codes returned by the javadoc tool.
* @see jdk.javadoc.internal.tool.Main.Result
*/
public enum Exit {
OK(0), // Javadoc completed with no errors.
ERROR(1), // Completed but reported errors.
CMDERR(2), // Bad command-line arguments
SYSERR(3), // System error or resource exhaustion.
ABNORMAL(4); // Javadoc terminated abnormally
Exit(int code) {
this.code = code;
}
final int code;
@Override
public String toString() {
return name() + '(' + code + ')';
}
}
/**
* Check the exit code of the most recent call of javadoc.
*
* @param expected the exit code that is required for the test
* to pass.
*/
public void checkExit(Exit expected) {
checking("check exit code");
if (exitCode == expected.code) {
passed("return code " + exitCode);
} else {
failed("return code " + exitCode +"; expected " + expected);
}
}
/**
* Check for content in (or not in) the generated output.
* Within the search strings, the newline character \n
* will be translated to the platform newline character sequence.
* @param path a path within the most recent output directory
* or the name of one of the output buffers, identifying
* where to look for the search strings.
* @param expectedFound true if all of the search strings are expected
* to be found, or false if the file is not expected to be found
* @param strings the strings to be searched for
*/
public void checkFileAndOutput(String path, boolean expectedFound, String... strings) {
if (expectedFound) {
checkOutput(path, true, strings);
} else {
checkFiles(false, path);
}
}
/**
* Check for content in (or not in) the generated output.
* Within the search strings, the newline character \n
* will be translated to the platform newline character sequence.
* @param path a path within the most recent output directory, identifying
* where to look for the search strings.
* @param expectedFound true if all of the search strings are expected
* to be found, or false if all of the strings are expected to be
* not found
* @param strings the strings to be searched for
*/
public void checkOutput(String path, boolean expectedFound, String... strings) {
// Read contents of file
try {
String fileString = readFile(outputDir, path);
checkOutput(new File(outputDir, path).getPath(), fileString, expectedFound, strings);
} catch (Error e) {
checking("Read file");
failed("Error reading file: " + e);
}
}
/**
* Check for content in (or not in) the one of the output streams written by
* javadoc. Within the search strings, the newline character \n
* will be translated to the platform newline character sequence.
* @param output the output stream to check
* @param expectedFound true if all of the search strings are expected
* to be found, or false if all of the strings are expected to be
* not found
* @param strings the strings to be searched for
*/
public void checkOutput(Output output, boolean expectedFound, String... strings) {
checkOutput(output.toString(), outputMap.get(output), expectedFound, strings);
}
// NOTE: path may be the name of an Output stream as well as a file path
private void checkOutput(String path, String fileString, boolean expectedFound, String... strings) {
for (String stringToFind : strings) {
// log.logCheckOutput(path, expectedFound, stringToFind);
checking("checkOutput");
// Find string in file's contents
boolean isFound = findString(fileString, stringToFind);
if (isFound == expectedFound) {
passed(path + ": following text " + (isFound ? "found:" : "not found:") + "\n"
+ stringToFind);
} else {
failed(path + ": following text " + (isFound ? "found:" : "not found:") + "\n"
+ stringToFind + '\n' +
"found \n" +
fileString);
}
}
}
public void checkLinks() {
checking("Check links");
LinkChecker c = new LinkChecker(out, this::readFile);
try {
c.checkDirectory(outputDir.toPath());
c.report();
int errors = c.getErrorCount();
if (errors == 0) {
passed("Links are OK");
} else {
failed(errors + " errors found when checking links");
}
} catch (IOException e) {
failed("exception thrown when reading files: " + e);
}
}
/**
* Get the content of the one of the output streams written by javadoc.
* @param output the name of the output stream
* @return the content of the output stream
*/
public String getOutput(Output output) {
return outputMap.get(output);
}
/**
* Get the content of the one of the output streams written by javadoc.
* @param output the name of the output stream
* @return the content of the output stream, as a line of lines
*/
public List<String> getOutputLines(Output output) {
String text = outputMap.get(output);
return (text == null) ? Collections.emptyList() : Arrays.asList(text.split(NL));
}
/**
* Check for files in (or not in) the generated output.
* @param expectedFound true if all of the files are expected
* to be found, or false if all of the files are expected to be
* not found
* @param paths the files to check, within the most recent output directory.
* */
public void checkFiles(boolean expectedFound, String... paths) {
checkFiles(expectedFound, Arrays.asList(paths));
}
/**
* Check for files in (or not in) the generated output.
* @param expectedFound true if all of the files are expected
* to be found, or false if all of the files are expected to be
* not found
* @param paths the files to check, within the most recent output directory.
* */
public void checkFiles(boolean expectedFound, Collection<String> paths) {
for (String path: paths) {
// log.logCheckFile(path, expectedFound);
checking("checkFile");
File file = new File(outputDir, path);
boolean isFound = file.exists();
if (isFound == expectedFound) {
passed(file, "file " + (isFound ? "found:" : "not found:") + "\n");
} else {
failed(file, "file " + (isFound ? "found:" : "not found:") + "\n");
}
}
}
/**
* Check that a series of strings are found in order in a file in
* the generated output.
* @param path the file to check
* @param strings the strings whose order to check
*/
public void checkOrder(String path, String... strings) {
File file = new File(outputDir, path);
String fileString = readOutputFile(path);
int prevIndex = -1;
for (String s : strings) {
s = s.replace("\n", NL); // normalize new lines
int currentIndex = fileString.indexOf(s, prevIndex + 1);
checking("file: " + file + ": " + s + " at index " + currentIndex);
if (currentIndex == -1) {
failed(file, s + " not found.");
continue;
}
if (currentIndex > prevIndex) {
passed(file, s + " is in the correct order");
} else {
failed(file, s + " is in the wrong order.");
}
prevIndex = currentIndex;
}
}
/**
* Ensures that a series of strings appear only once, in the generated output,
* noting that, this test does not exhaustively check for all other possible
* duplicates once one is found.
* @param path the file to check
* @param strings ensure each are unique
*/
public void checkUnique(String path, String... strings) {
File file = new File(outputDir, path);
String fileString = readOutputFile(path);
for (String s : strings) {
int currentIndex = fileString.indexOf(s);
checking(s + " at index " + currentIndex);
if (currentIndex == -1) {
failed(file, s + " not found.");
continue;
}
int nextindex = fileString.indexOf(s, currentIndex + s.length());
if (nextindex == -1) {
passed(file, s + " is unique");
} else {
failed(file, s + " is not unique, found at " + nextindex);
}
}
}
/**
* Compare a set of files in each of two directories.
*
* @param baseDir1 the directory containing the first set of files
* @param baseDir2 the directory containing the second set of files
* @param files the set of files to be compared
*/
public void diff(String baseDir1, String baseDir2, String... files) {
File bd1 = new File(baseDir1);
File bd2 = new File(baseDir2);
for (String file : files) {
diff(bd1, bd2, file);
}
}
/**
* A utility to copy a directory from one place to another.
*
* @param targetDir the directory to copy.
* @param destDir the destination to copy the directory to.
*/
// TODO: convert to using java.nio.Files.walkFileTree
public void copyDir(String targetDir, String destDir) {
try {
File targetDirObj = new File(targetDir);
File destDirParentObj = new File(destDir);
File destDirObj = new File(destDirParentObj, targetDirObj.getName());
if (! destDirParentObj.exists()) {
destDirParentObj.mkdir();
}
if (! destDirObj.exists()) {
destDirObj.mkdir();
}
String[] files = targetDirObj.list();
for (String file : files) {
File srcFile = new File(targetDirObj, file);
File destFile = new File(destDirObj, file);
if (srcFile.isFile()) {
out.println("Copying " + srcFile + " to " + destFile);
copyFile(destFile, srcFile);
} else if(srcFile.isDirectory()) {
copyDir(srcFile.getAbsolutePath(), destDirObj.getAbsolutePath());
}
}
} catch (IOException exc) {
throw new Error("Could not copy " + targetDir + " to " + destDir);
}
}
/**
* Copy source file to destination file.
*
* @param destfile the destination file
* @param srcfile the source file
* @throws IOException
*/
public void copyFile(File destfile, File srcfile) throws IOException {
Files.copy(srcfile.toPath(), destfile.toPath());
}
/**
* Read a file from the output directory.
*
* @param fileName the name of the file to read
* @return the file in string format
*/
public String readOutputFile(String fileName) throws Error {
return readFile(outputDir, fileName);
}
protected String readFile(String fileName) throws Error {
return readFile(outputDir, fileName);
}
protected String readFile(String baseDir, String fileName) throws Error {
return readFile(new File(baseDir), fileName);
}
private String readFile(Path file) {
File baseDir;
if (file.startsWith(outputDir.toPath())) {
baseDir = outputDir;
} else if (file.startsWith(currDir)) {
baseDir = currDir.toFile();
} else {
baseDir = file.getParent().toFile();
}
String fileName = baseDir.toPath().relativize(file).toString();
return readFile(baseDir, fileName);
}
/**
* Read the file and return it as a string.
*
* @param baseDir the directory in which to locate the file
* @param fileName the name of the file to read
* @return the file in string format
*/
private String readFile(File baseDir, String fileName) throws Error {
if (!Objects.equals(fileContentCacheCharset, charset)) {
fileContentCache.clear();
fileContentCacheCharset = charset;
}
try {
File file = new File(baseDir, fileName);
SoftReference<String> ref = fileContentCache.get(file);
String content = (ref == null) ? null : ref.get();
if (content != null)
return content;
// charset defaults to a value inferred from latest javadoc run
content = new String(Files.readAllBytes(file.toPath()), charset);
fileContentCache.put(file, new SoftReference<>(content));
return content;
} catch (FileNotFoundException e) {
throw new Error("File not found: " + fileName + ": " + e);
} catch (IOException e) {
throw new Error("Error reading file: " + fileName + ": " + e);
}
}
protected void checking(String message) {
numTestsRun++;
javadocTestNum++;
print("Starting subtest " + javadocRunNum + "." + javadocTestNum, message);
}
protected void passed(File file, String message) {
passed(file + ": " + message);
}
protected void passed(String message) {
numTestsPassed++;
print("Passed", message);
out.println();
}
protected void failed(File file, String message) {
failed(file + ": " + message);
}
protected void failed(String message) {
print("FAILED", message);
StackWalker.getInstance().walk(s -> {
s.dropWhile(f -> f.getMethodName().equals("failed"))
.takeWhile(f -> !f.getMethodName().equals("runTests"))
.forEach(f -> out.println(" at "
+ f.getClassName() + "." + f.getMethodName()
+ "(" + f.getFileName() + ":" + f.getLineNumber() + ")"));
return null;
});
out.println();
}
private void print(String prefix, String message) {
if (message.isEmpty())
out.println(prefix);
else {
out.print(prefix);
out.print(": ");
out.print(message.replace("\n", NL));
if (!(message.endsWith("\n") || message.endsWith(NL))) {
out.println();
}
}
}
/**
* Print a summary of the test results.
*/
protected void printSummary() {
String javadocRuns = (javadocRunNum <= 1) ? ""
: ", in " + javadocRunNum + " runs of javadoc";
if (numTestsRun != 0 && numTestsPassed == numTestsRun) {
// Test passed
out.println();
out.println("All " + numTestsPassed + " subtests passed" + javadocRuns);
} else {
// Test failed
throw new Error((numTestsRun - numTestsPassed)
+ " of " + (numTestsRun)
+ " subtests failed"
+ javadocRuns);
}
}
/**
* Search for the string in the given file and return true
* if the string was found.
*
* @param fileString the contents of the file to search through
* @param stringToFind the string to search for
* @return true if the string was found
*/
private boolean findString(String fileString, String stringToFind) {
// javadoc (should) always use the platform newline sequence,
// but in the strings to find it is more convenient to use the Java
// newline character. So we translate \n to NL before we search.
stringToFind = stringToFind.replace("\n", NL);
return fileString.contains(stringToFind);
}
/**
* Compare the two given files.
*
* @param baseDir1 the directory in which to locate the first file
* @param baseDir2 the directory in which to locate the second file
* @param file the file to compare in the two base directories
* @param throwErrorIFNoMatch flag to indicate whether or not to throw
* an error if the files do not match.
* @return true if the files are the same and false otherwise.
*/
private void diff(File baseDir1, File baseDir2, String file) {
String file1Contents = readFile(baseDir1, file);
String file2Contents = readFile(baseDir2, file);
checking("diff " + new File(baseDir1, file) + ", " + new File(baseDir2, file));
if (file1Contents.trim().compareTo(file2Contents.trim()) == 0) {
passed("files are equal");
} else {
failed("files differ");
}
}
/**
* Utility class to simplify the handling of temporarily setting a
* new stream for System.out or System.err.
*/
private static class StreamOutput {
// functional interface to set a stream.
private interface Initializer {
void set(PrintStream s);
}
private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
private final PrintStream ps = new PrintStream(baos);
private final PrintStream prev;
private final Initializer init;
StreamOutput(PrintStream s, Initializer init) {
prev = s;
init.set(ps);
this.init = init;
}
String close() {
init.set(prev);
ps.close();
return baos.toString();
}
}
/**
* Utility class to simplify the handling of creating an in-memory PrintWriter.
*/
private static class WriterOutput {
private final StringWriter sw = new StringWriter();
final PrintWriter pw = new PrintWriter(sw);
String close() {
pw.close();
return sw.toString();
}
}
// private final Logger log = new Logger();
//--------- Logging --------------------------------------------------------
//
// This class writes out the details of calls to checkOutput and checkFile
// in a canonical way, so that the resulting file can be checked against
// similar files from other versions of JavadocTester using the same logging
// facilities.
static class Logger {
private static final int PREFIX = 40;
private static final int SUFFIX = 20;
private static final int MAX = PREFIX + SUFFIX;
List<String> tests = new ArrayList<>();
String outDir;
String rootDir = rootDir();
static String rootDir() {
File f = new File(".").getAbsoluteFile();
while (!new File(f, ".hg").exists())
f = f.getParentFile();
return f.getPath();
}
void setOutDir(File outDir) {
this.outDir = outDir.getPath();
}
void logCheckFile(String file, boolean positive) {
// Strip the outdir because that will typically not be the same
if (file.startsWith(outDir + "/"))
file = file.substring(outDir.length() + 1);
tests.add(file + " " + positive);
}
void logCheckOutput(String file, boolean positive, String text) {
// Compress the string to be displayed in the log file
String simpleText = text.replaceAll("\\s+", " ").replace(rootDir, "[ROOT]");
if (simpleText.length() > MAX)
simpleText = simpleText.substring(0, PREFIX)
+ "..." + simpleText.substring(simpleText.length() - SUFFIX);
// Strip the outdir because that will typically not be the same
if (file.startsWith(outDir + "/"))
file = file.substring(outDir.length() + 1);
// The use of text.hashCode ensure that all of "text" is taken into account
tests.add(file + " " + positive + " " + text.hashCode() + " " + simpleText);
}
void write() {
// sort the log entries because the subtests may not be executed in the same order
tests.sort((a, b) -> a.compareTo(b));
try (BufferedWriter bw = new BufferedWriter(new FileWriter("tester.log"))) {
for (String t: tests) {
bw.write(t);
bw.newLine();
}
} catch (IOException e) {
throw new Error("problem writing log: " + e);
}
}
}
// Support classes for checkLinks
/**
* A basic HTML parser. Override the protected methods as needed to get notified
* of significant items in any file that is read.
*/
static abstract class HtmlParser {
protected final PrintStream out;
protected final Function<Path,String> fileReader;
private Path file;
private StringReader in;
private int ch;
private int lineNumber;
private boolean inScript;
private boolean xml;
HtmlParser(PrintStream out, Function<Path,String> fileReader) {
this.out = out;
this.fileReader = fileReader;
}
/**
* Read a file.
* @param file the file to be read
* @throws IOException if an error occurs while reading the file
*/
void read(Path file) throws IOException {
try (StringReader r = new StringReader(fileReader.apply(file))) {
this.file = file;
this.in = r;
startFile(file);
try {
lineNumber = 1;
xml = false;
nextChar();
while (ch != -1) {
switch (ch) {
case '<':
html();
break;
default:
nextChar();
}
}
} finally {
endFile();
}
} catch (IOException e) {
error(file, lineNumber, e);
} catch (Throwable t) {
error(file, lineNumber, t);
t.printStackTrace(out);
}
}
int getLineNumber() {
return lineNumber;
}
/**
* Called when a file has been opened, before parsing begins.
* This is always the first notification when reading a file.
* This implementation does nothing.
*
* @param file the file
*/
protected void startFile(Path file) { }
/**
* Called when the parser has finished reading a file.
* This is always the last notification when reading a file,
* unless any errors occur while closing the file.
* This implementation does nothing.
*/
protected void endFile() { }
/**
* Called when a doctype declaration is found, at the beginning of the file.
* This implementation does nothing.
* @param s the doctype declaration
*/
protected void docType(String s) { }
/**
* Called when the opening tag of an HTML element is encountered.
* This implementation does nothing.
* @param name the name of the tag
* @param attrs the attribute
* @param selfClosing whether or not this is a self-closing tag
*/
protected void startElement(String name, Map<String,String> attrs, boolean selfClosing) { }
/**
* Called when the closing tag of an HTML tag is encountered.
* This implementation does nothing.
* @param name the name of the tag
*/
protected void endElement(String name) { }
/**
* Called when an error has been encountered.
* @param file the file being read
* @param lineNumber the line number of line containing the error
* @param message a description of the error
*/
protected void error(Path file, int lineNumber, String message) {
out.println(file + ":" + lineNumber + ": " + message);
}
/**
* Called when an exception has been encountered.
* @param file the file being read
* @param lineNumber the line number of the line being read when the exception was found
* @param t the exception
*/
protected void error(Path file, int lineNumber, Throwable t) {
out.println(file + ":" + lineNumber + ": " + t);
}
private void nextChar() throws IOException {
ch = in.read();
if (ch == '\n')
lineNumber++;
}
/**
* Read the start or end of an HTML tag, or an HTML comment
* {@literal <identifier attrs> } or {@literal </identifier> }
* @throws java.io.IOException if there is a problem reading the file
*/
private void html() throws IOException {
nextChar();
if (isIdentifierStart((char) ch)) {
String name = readIdentifier().toLowerCase(Locale.US);
Map<String,String> attrs = htmlAttrs();
if (attrs != null) {
boolean selfClosing = false;
if (ch == '/') {
nextChar();
selfClosing = true;
}
if (ch == '>') {
nextChar();
startElement(name, attrs, selfClosing);
if (name.equals("script")) {
inScript = true;
}
return;
}
}
} else if (ch == '/') {
nextChar();
if (isIdentifierStart((char) ch)) {
String name = readIdentifier().toLowerCase(Locale.US);
skipWhitespace();
if (ch == '>') {
nextChar();
endElement(name);
if (name.equals("script")) {
inScript = false;
}
return;
}
}
} else if (ch == '!') {
nextChar();
if (ch == '-') {
nextChar();
if (ch == '-') {
nextChar();
while (ch != -1) {
int dash = 0;
while (ch == '-') {
dash++;
nextChar();
}
// Strictly speaking, a comment should not contain "--"
// so dash > 2 is an error, dash == 2 implies ch == '>'
// See http://www.w3.org/TR/html-markup/syntax.html#syntax-comments
// for more details.
if (dash >= 2 && ch == '>') {
nextChar();
return;
}
nextChar();
}
}
} else if (ch == '[') {
nextChar();
if (ch == 'C') {
nextChar();
if (ch == 'D') {
nextChar();
if (ch == 'A') {
nextChar();
if (ch == 'T') {
nextChar();
if (ch == 'A') {
nextChar();
if (ch == '[') {
while (true) {
nextChar();
if (ch == ']') {
nextChar();
if (ch == ']') {
nextChar();
if (ch == '>') {
nextChar();
return;
}
}
}
}
}
}
}
}
}
}
} else {
StringBuilder sb = new StringBuilder();
while (ch != -1 && ch != '>') {
sb.append((char) ch);
nextChar();
}
Pattern p = Pattern.compile("(?is)doctype\\s+html\\s?.*");
String s = sb.toString();
if (p.matcher(s).matches()) {
docType(s);
return;
}
}
} else if (ch == '?') {
nextChar();
if (ch == 'x') {
nextChar();
if (ch == 'm') {
nextChar();
if (ch == 'l') {
Map<String,String> attrs = htmlAttrs();
if (ch == '?') {
nextChar();
if (ch == '>') {
nextChar();
xml = true;
return;
}
}
}
}
}
}
if (!inScript) {
error(file, lineNumber, "bad html");
}
}
/**
* Read a series of HTML attributes, terminated by {@literal > }.
* Each attribute is of the form {@literal identifier[=value] }.
* "value" may be unquoted, single-quoted, or double-quoted.
*/
private Map<String,String> htmlAttrs() throws IOException {
Map<String, String> map = new LinkedHashMap<>();
skipWhitespace();
loop:
while (isIdentifierStart((char) ch)) {
String name = readAttributeName().toLowerCase(Locale.US);
skipWhitespace();
String value = null;
if (ch == '=') {
nextChar();
skipWhitespace();
if (ch == '\'' || ch == '"') {
char quote = (char) ch;
nextChar();
StringBuilder sb = new StringBuilder();
while (ch != -1 && ch != quote) {
sb.append((char) ch);
nextChar();
}
value = sb.toString() // hack to replace common entities
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&");
nextChar();
} else {
StringBuilder sb = new StringBuilder();
while (ch != -1 && !isUnquotedAttrValueTerminator((char) ch)) {
sb.append((char) ch);
nextChar();
}
value = sb.toString();
}
skipWhitespace();
}
map.put(name, value);
}
return map;
}
private boolean isIdentifierStart(char ch) {
return Character.isUnicodeIdentifierStart(ch);
}
private String readIdentifier() throws IOException {
StringBuilder sb = new StringBuilder();
sb.append((char) ch);
nextChar();
while (ch != -1 && Character.isUnicodeIdentifierPart(ch)) {
sb.append((char) ch);
nextChar();
}
return sb.toString();
}
private String readAttributeName() throws IOException {
StringBuilder sb = new StringBuilder();
sb.append((char) ch);
nextChar();
while (ch != -1 && Character.isUnicodeIdentifierPart(ch)
|| ch == '-'
|| xml && ch == ':') {
sb.append((char) ch);
nextChar();
}
return sb.toString();
}
private boolean isWhitespace(char ch) {
return Character.isWhitespace(ch);
}
private void skipWhitespace() throws IOException {
while (isWhitespace((char) ch)) {
nextChar();
}
}
private boolean isUnquotedAttrValueTerminator(char ch) {
switch (ch) {
case '\f': case '\n': case '\r': case '\t':
case ' ':
case '"': case '\'': case '`':
case '=': case '<': case '>':
return true;
default:
return false;
}
}
}
/**
* A class to check the links in a set of HTML files.
*/
static class LinkChecker extends HtmlParser {
private final Map<Path, IDTable> allFiles;
private final Map<URI, IDTable> allURIs;
private int files;
private int links;
private int badSchemes;
private int duplicateIds;
private int missingIds;
private Path currFile;
private IDTable currTable;
private boolean html5;
private boolean xml;
private int errors;
LinkChecker(PrintStream out, Function<Path,String> fileReader) {
super(out, fileReader);
allFiles = new HashMap<>();
allURIs = new HashMap<>();
}
void checkDirectory(Path dir) throws IOException {
checkFiles(List.of(dir), false, Collections.emptySet());
}
void checkFiles(List<Path> files, boolean skipSubdirs, Set<Path> excludeFiles) throws IOException {
for (Path file : files) {
Files.walkFileTree(file, new SimpleFileVisitor<Path>() {
int depth = 0;
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if ((skipSubdirs && depth > 0) || excludeFiles.contains(dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
depth++;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path p, BasicFileAttributes attrs) {
if (excludeFiles.contains(p)) {
return FileVisitResult.CONTINUE;
}
if (Files.isRegularFile(p) && p.getFileName().toString().endsWith(".html")) {
checkFile(p);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException {
depth--;
return super.postVisitDirectory(dir, e);
}
});
}
}
void checkFile(Path file) {
try {
read(file);
} catch (IOException e) {
error(file, 0, e);
}
}
int getErrorCount() {
return errors;
}
public void report() {
List<Path> missingFiles = getMissingFiles();
if (!missingFiles.isEmpty()) {
report("Missing files: (" + missingFiles.size() + ")");
missingFiles.stream()
.sorted()
.forEach(this::reportMissingFile);
}
if (!allURIs.isEmpty()) {
report(false, "External URLs:");
allURIs.keySet().stream()
.sorted(new URIComparator())
.forEach(uri -> report(false, " %s", uri.toString()));
}
int anchors = 0;
for (IDTable t : allFiles.values()) {
anchors += t.map.values().stream()
.filter(e -> !e.getReferences().isEmpty())
.count();
}
for (IDTable t : allURIs.values()) {
anchors += t.map.values().stream()
.filter(e -> !e.references.isEmpty())
.count();
}
report(false, "Checked " + files + " files.");
report(false, "Found " + links + " references to " + anchors + " anchors "
+ "in " + allFiles.size() + " files and " + allURIs.size() + " other URIs.");
report(!missingFiles.isEmpty(), "%6d missing files", missingFiles.size());
report(duplicateIds > 0, "%6d duplicate ids", duplicateIds);
report(missingIds > 0, "%6d missing ids", missingIds);
Map<String, Integer> schemeCounts = new TreeMap<>();
Map<String, Integer> hostCounts = new TreeMap<>(new HostComparator());
for (URI uri : allURIs.keySet()) {
String scheme = uri.getScheme();
if (scheme != null) {
schemeCounts.put(scheme, schemeCounts.computeIfAbsent(scheme, s -> 0) + 1);
}
String host = uri.getHost();
if (host != null) {
hostCounts.put(host, hostCounts.computeIfAbsent(host, h -> 0) + 1);
}
}
if (schemeCounts.size() > 0) {
report(false, "Schemes");
schemeCounts.forEach((s, n) -> report(!isSchemeOK(s), "%6d %s", n, s));
}
if (hostCounts.size() > 0) {
report(false, "Hosts");
hostCounts.forEach((h, n) -> report(false, "%6d %s", n, h));
}
}
private void report(String message, Object... args) {
out.println(String.format(message, args));
}
private void report(boolean highlight, String message, Object... args) {
out.print(highlight ? "* " : " ");
out.println(String.format(message, args));
}
private void reportMissingFile(Path file) {
report("%s", relativePath(file));
IDTable table = allFiles.get(file);
Set<Path> refs = new TreeSet<>();
for (ID id : table.map.values()) {
if (id.references != null) {
for (Position p : id.references) {
refs.add(p.path);
}
}
}
int n = 0;
int MAX_REFS = 10;
for (Path ref : refs) {
report(" in " + relativePath(ref));
if (++n == MAX_REFS) {
report(" ... and %d more", refs.size() - n);
break;
}
}
}
@Override
public void startFile(Path path) {
currFile = path.toAbsolutePath().normalize();
currTable = allFiles.computeIfAbsent(currFile, p -> new IDTable(p));
html5 = false;
files++;
}
@Override
public void endFile() {
currTable.check();
}
@Override
public void docType(String doctype) {
html5 = doctype.matches("(?i)<\\?doctype\\s+html>");
}
@Override @SuppressWarnings("fallthrough")
public void startElement(String name, Map<String, String> attrs, boolean selfClosing) {
int line = getLineNumber();
switch (name) {
case "a":
String nameAttr = html5 ? null : attrs.get("name");
if (nameAttr != null) {
foundAnchor(line, nameAttr);
}
// fallthrough
case "link":
String href = attrs.get("href");
if (href != null) {
foundReference(line, href);
}
break;
}
String idAttr = attrs.get("id");
if (idAttr != null) {
foundAnchor(line, idAttr);
}
}
@Override
public void endElement(String name) { }
private void foundAnchor(int line, String name) {
currTable.addID(line, name);
}
private void foundReference(int line, String ref) {
links++;
try {
URI uri = new URI(ref);
if (uri.isAbsolute()) {
foundReference(line, uri);
} else {
Path p;
String uriPath = uri.getPath();
if (uriPath == null || uriPath.isEmpty()) {
p = currFile;
} else {
p = currFile.getParent().resolve(uriPath).normalize();
}
foundReference(line, p, uri.getFragment());
}
} catch (URISyntaxException e) {
error(currFile, line, "invalid URI: " + e);
}
}
private void foundReference(int line, Path p, String fragment) {
IDTable t = allFiles.computeIfAbsent(p, key -> new IDTable(key));
t.addReference(fragment, currFile, line);
}
private void foundReference(int line, URI uri) {
if (!isSchemeOK(uri.getScheme())) {
error(currFile, line, "bad scheme in URI");
badSchemes++;
}
String fragment = uri.getFragment();
try {
URI noFrag = new URI(uri.toString().replaceAll("#\\Q" + fragment + "\\E$", ""));
IDTable t = allURIs.computeIfAbsent(noFrag, key -> new IDTable(key.toString()));
t.addReference(fragment, currFile, line);
} catch (URISyntaxException e) {
throw new Error(e);
}
}
private boolean isSchemeOK(String uriScheme) {
if (uriScheme == null) {
return true;
}
switch (uriScheme) {
case "file":
case "ftp":
case "http":
case "https":
case "javascript":
case "mailto":
return true;
default:
return false;
}
}
private List<Path> getMissingFiles() {
return allFiles.entrySet().stream()
.filter(e -> !Files.exists(e.getKey()))
.map(e -> e.getKey())
.collect(Collectors.toList());
}
@Override
protected void error(Path file, int lineNumber, String message) {
super.error(relativePath(file), lineNumber, message);
errors++;
}
@Override
protected void error(Path file, int lineNumber, Throwable t) {
super.error(relativePath(file), lineNumber, t);
errors++;
}
private Path relativePath(Path path) {
return path.startsWith(currDir) ? currDir.relativize(path) : path;
}
/**
* A position in a file, as identified by a file name and line number.
*/
static class Position implements Comparable<Position> {
Path path;
int line;
Position(Path path, int line) {
this.path = path;
this.line = line;
}
@Override
public int compareTo(Position o) {
int v = path.compareTo(o.path);
return v != 0 ? v : Integer.compare(line, o.line);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
} else if (obj == null || getClass() != obj.getClass()) {
return false;
} else {
final Position other = (Position) obj;
return Objects.equals(this.path, other.path)
&& this.line == other.line;
}
}
@Override
public int hashCode() {
return Objects.hashCode(path) * 37 + line;
}
}
/**
* Infor for an ID within an HTML file, and a set of positions that reference it.
*/
static class ID {
boolean declared;
Set<Position> references;
Set<Position> getReferences() {
return (references) == null ? Collections.emptySet() : references;
}
}
/**
* A table for the set of IDs in an HTML file.
*/
class IDTable {
private String name;
private boolean checked;
private final Map<String, ID> map = new HashMap<>();
IDTable(Path p) {
this(relativePath(p).toString());
}
IDTable(String name) {
this.name = name;
}
void addID(int line, String name) {
if (checked) {
throw new IllegalStateException("Adding ID after file has been read");
}
Objects.requireNonNull(name);
ID id = map.computeIfAbsent(name, x -> new ID());
if (id.declared) {
error(currFile, line, "name already declared: " + name);
duplicateIds++;
} else {
id.declared = true;
}
}
void addReference(String name, Path from, int line) {
if (checked) {
if (name != null) {
ID id = map.get(name);
if (id == null || !id.declared) {
error(from, line, "id not found: " + this.name + "#" + name);
}
}
} else {
ID id = map.computeIfAbsent(name, x -> new ID());
if (id.references == null) {
id.references = new TreeSet<>();
}
id.references.add(new Position(from, line));
}
}
void check() {
map.forEach((name, id) -> {
if (name != null && !id.declared) {
//log.error(currFile, 0, "id not declared: " + name);
for (Position ref : id.references) {
error(ref.path, ref.line, "id not found: " + this.name + "#" + name);
}
missingIds++;
}
});
checked = true;
}
}
static class URIComparator implements Comparator<URI> {
final HostComparator hostComparator = new HostComparator();
@Override
public int compare(URI o1, URI o2) {
if (o1.isOpaque() || o2.isOpaque()) {
return o1.compareTo(o2);
}
String h1 = o1.getHost();
String h2 = o2.getHost();
String s1 = o1.getScheme();
String s2 = o2.getScheme();
if (h1 == null || h1.isEmpty() || s1 == null || s1.isEmpty()
|| h2 == null || h2.isEmpty() || s2 == null || s2.isEmpty()) {
return o1.compareTo(o2);
}
int v = hostComparator.compare(h1, h2);
if (v != 0) {
return v;
}
v = s1.compareTo(s2);
if (v != 0) {
return v;
}
return o1.compareTo(o2);
}
}
static class HostComparator implements Comparator<String> {
@Override
public int compare(String h1, String h2) {
List<String> l1 = new ArrayList<>(Arrays.asList(h1.split("\\.")));
Collections.reverse(l1);
String r1 = String.join(".", l1);
List<String> l2 = new ArrayList<>(Arrays.asList(h2.split("\\.")));
Collections.reverse(l2);
String r2 = String.join(".", l2);
return r1.compareTo(r2);
}
}
}
}