software.amazon.smithy.linters.NoninclusiveTermsValidator Maven / Gradle / Ivy
/*
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file 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 software.amazon.smithy.linters;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.SourceLocation;
import software.amazon.smithy.model.knowledge.TextIndex;
import software.amazon.smithy.model.knowledge.TextInstance;
import software.amazon.smithy.model.node.NodeMapper;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.validation.AbstractValidator;
import software.amazon.smithy.model.validation.Severity;
import software.amazon.smithy.model.validation.ValidationEvent;
import software.amazon.smithy.model.validation.ValidationUtils;
import software.amazon.smithy.model.validation.ValidatorService;
import software.amazon.smithy.utils.ListUtils;
import software.amazon.smithy.utils.MapUtils;
import software.amazon.smithy.utils.StringUtils;
/**
* Validates that all shape names and values do not contain non-inclusive terms.
*/
public final class NoninclusiveTermsValidator extends AbstractValidator {
static final Map> BUILT_IN_NONINCLUSIVE_TERMS = MapUtils.of(
"master", ListUtils.of("primary", "parent", "main"),
"slave", ListUtils.of("secondary", "replica", "clone", "child"),
"blacklist", ListUtils.of("denyList"),
"whitelist", ListUtils.of("allowList")
);
public static final class Provider extends ValidatorService.Provider {
public Provider() {
super(NoninclusiveTermsValidator.class, node -> {
NodeMapper mapper = new NodeMapper();
return new NoninclusiveTermsValidator(
mapper.deserialize(node, NoninclusiveTermsValidator.Config.class));
});
}
}
private static final String TRAIT = "Trait";
private static final String SHAPE = "Shape";
private static final String NAMESPACE = "Namespace";
/**
* NoninclusiveTermsValidator configuration.
*/
public static final class Config {
private Map> terms = MapUtils.of();
private boolean excludeDefaults;
public Map> getTerms() {
return terms;
}
public void setTerms(Map> terms) {
this.terms = terms;
}
public boolean getExcludeDefaults() {
return excludeDefaults;
}
public void setExcludeDefaults(boolean excludeDefaults) {
this.excludeDefaults = excludeDefaults;
}
}
private final Map> termsMap;
private NoninclusiveTermsValidator(Config config) {
Map> termsMapInit = new HashMap<>(BUILT_IN_NONINCLUSIVE_TERMS);
if (!config.getExcludeDefaults()) {
termsMapInit.putAll(config.getTerms());
termsMap = Collections.unmodifiableMap(termsMapInit);
} else {
if (config.getTerms().isEmpty()) {
//This configuration combination makes the validator a no-op.
throw new IllegalArgumentException("Cannot set 'excludeDefaults' to true and leave "
+ "'terms' empty or unspecified.");
}
termsMap = Collections.unmodifiableMap(config.getTerms());
}
}
/**
* Runs a full text scan on a given model and stores the resulting TextOccurrences objects.
*
* Namespaces are checked against a global set per model.
*
* @param model Model to validate.
* @return a list of ValidationEvents found by the implementer of getValidationEvents per the
* TextOccurrences provided by this traversal.
*/
@Override
public List validate(Model model) {
TextIndex textIndex = TextIndex.of(model);
List validationEvents = new ArrayList<>();
for (TextInstance text : textIndex.getTextInstances()) {
validationEvents.addAll(getValidationEvents(text));
}
return validationEvents;
}
/**
* Generates zero or more @see ValidationEvents and returns them in a collection.
*
* @param instance text occurrence found in the body of the model
*/
private Collection getValidationEvents(TextInstance instance) {
final Collection events = new ArrayList<>();
for (Map.Entry> termEntry : termsMap.entrySet()) {
final String termLower = termEntry.getKey().toLowerCase();
final int startIndex = instance.getText().toLowerCase().indexOf(termLower);
if (startIndex != -1) {
final String matchedText = instance.getText().substring(startIndex, startIndex + termLower.length());
events.add(constructValidationEvent(instance, termEntry.getValue(), matchedText));
}
}
return events;
}
private ValidationEvent constructValidationEvent(TextInstance instance,
List replacements,
String matchedText) {
String replacementAddendum = getReplacementAddendum(matchedText, replacements);
switch (instance.getLocationType()) {
case NAMESPACE:
//Cannot use any warning() overloads because there is no shape associated with the event.
return ValidationEvent.builder()
.severity(Severity.WARNING)
.sourceLocation(SourceLocation.none())
.id(getName() + "." + NAMESPACE + "." + instance.getText()
+ "." + matchedText.toLowerCase(Locale.US))
.message(String.format("%s namespace uses a non-inclusive term `%s`.%s",
instance.getText(), matchedText, replacementAddendum))
.build();
case APPLIED_TRAIT:
ValidationEvent validationEvent =
warning(instance.getShape(), instance.getTrait().getSourceLocation(), "");
String idiomaticTraitName = Trait.getIdiomaticTraitName(instance.getTrait());
if (instance.getTraitPropertyPath().isEmpty()) {
return validationEvent.toBuilder()
.message(String.format("'%s' trait has a value that contains a non-inclusive term `%s`.%s",
idiomaticTraitName, matchedText, replacementAddendum))
.id(getName() + "." + TRAIT + "."
+ matchedText.toLowerCase(Locale.US) + "." + idiomaticTraitName)
.build();
} else {
String valuePropertyPathFormatted = formatPropertyPath(instance.getTraitPropertyPath());
return validationEvent.toBuilder()
.message(String.format(
"'%s' trait value at path {%s} contains a non-inclusive term `%s`.%s",
idiomaticTraitName, valuePropertyPathFormatted, matchedText, replacementAddendum))
.id(getName() + "." + TRAIT + "." + matchedText.toLowerCase(Locale.US)
+ "." + idiomaticTraitName + "." + valuePropertyPathFormatted)
.build();
}
case SHAPE:
default:
return warning(instance.getShape(),
instance.getShape().getSourceLocation(),
String.format("%s shape uses a non-inclusive term `%s`.%s",
StringUtils.capitalize(instance.getShape().getType().toString()),
matchedText, replacementAddendum),
SHAPE, matchedText.toLowerCase(Locale.US));
}
}
private static String getReplacementAddendum(String matchedText, List replacements) {
List caseCorrectedEntryValue = replacements.stream()
.map(replacement -> Character.isUpperCase(matchedText.charAt(0))
? StringUtils.capitalize(replacement)
: StringUtils.uncapitalize(replacement))
.collect(Collectors.toList());
String replacementAddendum = !replacements.isEmpty()
? String.format(" Consider using one of the following terms instead: %s",
ValidationUtils.tickedList(caseCorrectedEntryValue))
: "";
return replacementAddendum;
}
private static String formatPropertyPath(List traitPropertyPath) {
return String.join("/", traitPropertyPath);
}
}