blob: 5b6b5b50c9b69c6dd394af7417bc593c5731abda [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.flink.docs.configuration;
import org.apache.flink.configuration.ConfigOption;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.Assert;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.apache.flink.docs.configuration.ConfigOptionsDocGenerator.LOCATIONS;
import static org.apache.flink.docs.configuration.ConfigOptionsDocGenerator.extractConfigOptions;
import static org.apache.flink.docs.configuration.ConfigOptionsDocGenerator.processConfigOptions;
import static org.apache.flink.docs.configuration.ConfigOptionsDocGenerator.stringifyDefault;
/**
* This test verifies that all {@link ConfigOption ConfigOptions} in the configured
* {@link ConfigOptionsDocGenerator#LOCATIONS locations} are documented and well-defined (i.e. no 2 options exist for
* the same key with different descriptions/default values), and that the documentation does not refer to non-existent
* options.
*/
public class ConfigOptionsDocsCompletenessITCase {
@Test
public void testDocsCompleteness() throws IOException, ClassNotFoundException {
Map<String, DocumentedOption> documentedOptions = parseDocumentedOptions();
Map<String, ExistingOption> existingOptions = findExistingOptions();
final Collection<String> problems = new ArrayList<>(0);
// first check that all existing options are properly documented
existingOptions.forEach((key, supposedState) -> {
DocumentedOption documentedState = documentedOptions.remove(key);
// if nothing matches the docs for this option are up-to-date
if (documentedState == null) {
// option is not documented at all
problems.add("Option " + supposedState.key + " in " + supposedState.containingClass + " is not documented.");
} else if (!supposedState.defaultValue.equals(documentedState.defaultValue)) {
// default is outdated
problems.add("Documented default of " + supposedState.key + " in " + supposedState.containingClass +
" is outdated. Expected: " + supposedState.defaultValue + " Actual: " + documentedState.defaultValue);
} else if (!supposedState.description.equals(documentedState.description)) {
// description is outdated
problems.add("Documented description of " + supposedState.key + " in " + supposedState.containingClass +
" is outdated.");
}
});
// documentation contains an option that no longer exists
if (!documentedOptions.isEmpty()) {
for (DocumentedOption documentedOption : documentedOptions.values()) {
problems.add("Documented option " + documentedOption.key + " does not exist.");
}
}
if (!problems.isEmpty()) {
StringBuilder sb = new StringBuilder("Documentation is outdated, please regenerate it according to the" +
" instructions in flink-docs/README.md.");
sb.append(System.lineSeparator());
sb.append("\tProblems:");
for (String problem : problems) {
sb.append(System.lineSeparator());
sb.append("\t\t");
sb.append(problem);
}
Assert.fail(sb.toString());
}
}
private static Map<String, DocumentedOption> parseDocumentedOptions() throws IOException {
Path includeFolder = Paths.get(System.getProperty("rootDir"), "docs", "_includes", "generated").toAbsolutePath();
return Files.list(includeFolder)
.filter(path -> path.getFileName().toString().contains("configuration"))
.flatMap(file -> {
try {
return parseDocumentedOptionsFromFile(file).stream();
} catch (IOException ignored) {
return Stream.empty();
}
})
.collect(Collectors.toMap(option -> option.key, option -> option, (option1, option2) -> {
if (option1.equals(option2)) {
// we allow multiple instances of ConfigOptions with the same key if they are identical
return option1;
} else {
// found a ConfigOption pair with the same key that aren't equal
// we fail here outright as this is not a documentation-completeness problem
if (!option1.defaultValue.equals(option2.defaultValue)) {
throw new AssertionError("Documentation contains distinct defaults for " +
option1.key + " in " + option1.containingFile + " and " + option2.containingFile + '.');
} else {
throw new AssertionError("Documentation contains distinct descriptions for " +
option1.key + " in " + option1.containingFile + " and " + option2.containingFile + '.');
}
}
}));
}
private static Collection<DocumentedOption> parseDocumentedOptionsFromFile(Path file) throws IOException {
Document document = Jsoup.parse(file.toFile(), StandardCharsets.UTF_8.name());
return document.getElementsByTag("table").stream()
.map(element -> element.getElementsByTag("tbody").get(0))
.flatMap(element -> element.getElementsByTag("tr").stream())
.map(tableRow -> {
String key = tableRow.child(0).text();
String defaultValue = tableRow.child(1).text();
String description = tableRow.child(2).text();
return new DocumentedOption(key, defaultValue, description, file.getName(file.getNameCount() - 1));
})
.collect(Collectors.toList());
}
private static Map<String, ExistingOption> findExistingOptions() throws IOException, ClassNotFoundException {
Map<String, ExistingOption> existingOptions = new HashMap<>(32);
for (OptionsClassLocation location : LOCATIONS) {
processConfigOptions(System.getProperty("rootDir"), location.getModule(), location.getPackage(), optionsClass -> {
List<ConfigOptionsDocGenerator.OptionWithMetaInfo> configOptions = extractConfigOptions(optionsClass);
for (ConfigOptionsDocGenerator.OptionWithMetaInfo option : configOptions) {
String key = option.option.key();
String defaultValue = stringifyDefault(option);
String description = option.option.description();
ExistingOption duplicate = existingOptions.put(key, new ExistingOption(key, defaultValue, description, optionsClass));
if (duplicate != null) {
// multiple documented options have the same key
// we fail here outright as this is not a documentation-completeness problem
if (!(duplicate.description.equals(description))) {
throw new AssertionError("Ambiguous option " + key + " due to distinct descriptions.");
} else if (!duplicate.defaultValue.equals(defaultValue)) {
throw new AssertionError("Ambiguous option " + key + " due to distinct default values (" + defaultValue + " vs " + duplicate.defaultValue + ").");
}
}
}
});
}
return existingOptions;
}
private static final class ExistingOption extends Option {
private final Class<?> containingClass;
private ExistingOption(String key, String defaultValue, String description, Class<?> containingClass) {
super(key, defaultValue, description);
this.containingClass = containingClass;
}
}
private static final class DocumentedOption extends Option {
private final Path containingFile;
private DocumentedOption(String key, String defaultValue, String description, Path containingFile) {
super(key, defaultValue, description);
this.containingFile = containingFile;
}
}
private abstract static class Option {
protected final String key;
protected final String defaultValue;
protected final String description;
private Option(String key, String defaultValue, String description) {
this.key = key;
this.defaultValue = defaultValue;
this.description = description;
}
@Override
public int hashCode() {
return key.hashCode() + defaultValue.hashCode() + description.hashCode();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Option)) {
return false;
}
Option other = (Option) obj;
return this.key.equals(other.key)
&& this.defaultValue.equals(other.defaultValue)
&& this.description.equals(other.description);
}
@Override
public String toString() {
return "Option(key=" + key + ", default=" + defaultValue + ", description=" + description + ')';
}
}
}