From b734074f2f2256e81f53e4cf9e973c6f6134cd65 Mon Sep 17 00:00:00 2001 From: David Oguns Date: Fri, 8 Oct 2021 10:31:53 -0700 Subject: [PATCH] Add noninclusive terms filter. --- docs/source/1.0/guides/model-linters.rst | 67 ++++++ .../linters/NoninclusiveTermsValidator.java | 200 ++++++++++++++++++ ...n.smithy.model.validation.ValidatorService | 1 + .../noninclusive-term-filter-append.errors | 6 + .../noninclusive-term-filter-append.json | 63 ++++++ .../noninclusive-term-filter-override.errors | 3 + .../noninclusive-term-filter-override.json | 60 ++++++ .../noninclusive-term-filter.errors | 13 ++ .../errorfiles/noninclusive-term-filter.json | 128 +++++++++++ .../smithy/model/knowledge/TextIndex.java | 138 ++++++++++++ .../smithy/model/knowledge/TextInstance.java | 124 +++++++++++ 11 files changed, 803 insertions(+) create mode 100644 smithy-linters/src/main/java/software/amazon/smithy/linters/NoninclusiveTermsValidator.java create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.errors create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.json create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.errors create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.json create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.errors create mode 100644 smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.json create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextIndex.java create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextInstance.java diff --git a/docs/source/1.0/guides/model-linters.rst b/docs/source/1.0/guides/model-linters.rst index 02c861ca001..5dc89e96a9e 100644 --- a/docs/source/1.0/guides/model-linters.rst +++ b/docs/source/1.0/guides/model-linters.rst @@ -128,6 +128,73 @@ Example: ] +.. _NoninclusiveTerms: + +NoninclusiveTerms +================= + +Validates that all text content in a model (i.e. shape names, member names, +documentation, trait values, etc.) does not contain words that perpetuate cultural +biases. This validator has a built-in set of bias terms that are commonly found +in APIs along with suggested alternatives. + +Noninclusive terms are case-insensitively substring matched and can have any +number of leading or trailing whitespace or non-whitespace characters. + +This validator has built-in mappings from noninclusive terms to match model +text to suggested alternatives. The configuration allows for additional terms +to suggestions mappings to either override or append the built-in mappings. If +a match occurs and the suggested alternatives is empty, no suggestion is made +in the generated warning message. + +Rationale + Intent doesn't always match impact. The use of noninclusive language like + "whitelist" and "blacklist" perpetuates bias through past association of + acceptance and denial based on skin color. Other words should be used that + are not only inclusive, but more clearly communicate meaning. Words like + allowList and denyList much more clearly indicate that something is + allowed or denied. + +Default severity + ``WARNING`` + +Configuration + .. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - Property + - Type + - Description + * - terms + - { ``keyword`` -> [ ``alternatives`` ] } + - A set of noninclusive terms to suggestions to either override or replace + the built-in mappings. This property is not required unless + ``excludeDefaults`` is true. The default value is the empty set. + * - excludeDefaults + - ``boolean`` + - A flag indicating whether or not the mappings set specified by ``terms`` + configuration replaces the built-in set or appends additional mappings. + This property is not required and defaults to false. + +Example: + +.. code-block:: smithy + + $version: "1.0" + + metadata validators = [{ + name: "NoninclusiveTerms" + configuration: { + excludeDefaults: false, + terms: { + mankind: ["humankind"], + mailman: ["mail carrier", "postal worker"] + } + } + }] + + .. _ReservedWords: ReservedWords diff --git a/smithy-linters/src/main/java/software/amazon/smithy/linters/NoninclusiveTermsValidator.java b/smithy-linters/src/main/java/software/amazon/smithy/linters/NoninclusiveTermsValidator.java new file mode 100644 index 00000000000..4e688004be3 --- /dev/null +++ b/smithy-linters/src/main/java/software/amazon/smithy/linters/NoninclusiveTermsValidator.java @@ -0,0 +1,200 @@ +/* + * 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.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)); + }); + } + } + + /** + * 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 occurrence 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()); + switch (instance.getLocationType()) { + case NAMESPACE: + //Cannot use any warning() overloads because there is no shape associated with the event. + events.add(ValidationEvent.builder() + .sourceLocation(SourceLocation.none()) + .id(this.getClass().getSimpleName().replaceFirst("Validator$", "")) + .severity(Severity.WARNING) + .message(formatNonInclusiveTermsValidationMessage(termEntry, matchedText, instance)) + .build()); + break; + case APPLIED_TRAIT: + events.add(warning(instance.getShape(), + instance.getTrait().getSourceLocation(), + formatNonInclusiveTermsValidationMessage(termEntry, matchedText, instance))); + break; + case SHAPE: + default: + events.add(warning(instance.getShape(), + instance.getShape().getSourceLocation(), + formatNonInclusiveTermsValidationMessage(termEntry, matchedText, instance))); + } + } + } + return events; + } + + private static String formatNonInclusiveTermsValidationMessage( + Map.Entry> termEntry, + String matchedText, + TextInstance instance + ) { + final List caseCorrectedEntryValue = termEntry.getValue().stream() + .map(replacement -> Character.isUpperCase(matchedText.charAt(0)) + ? StringUtils.capitalize(replacement) + : StringUtils.uncapitalize(replacement)) + .collect(Collectors.toList()); + String replacementAddendum = !termEntry.getValue().isEmpty() + ? String.format(" Consider using one of the following terms instead: %s", + ValidationUtils.tickedList(caseCorrectedEntryValue)) + : ""; + switch (instance.getLocationType()) { + case SHAPE: + return String.format("%s shape uses a non-inclusive term `%s`.%s", + StringUtils.capitalize(instance.getShape().getType().toString()), + matchedText, replacementAddendum); + case NAMESPACE: + return String.format("%s namespace uses a non-inclusive term `%s`.%s", + instance.getText(), matchedText, replacementAddendum); + case APPLIED_TRAIT: + if (instance.getTraitPropertyPath().isEmpty()) { + return String.format("'%s' trait has a value that contains a non-inclusive term `%s`.%s", + Trait.getIdiomaticTraitName(instance.getTrait()), matchedText, + replacementAddendum); + } else { + String valuePropertyPathFormatted = formatPropertyPath(instance.getTraitPropertyPath()); + return String.format("'%s' trait value at path {%s} contains a non-inclusive term `%s`.%s", + Trait.getIdiomaticTraitName(instance.getTrait()), valuePropertyPathFormatted, + matchedText, replacementAddendum); + } + default: + throw new IllegalStateException(); + } + } + + private static String formatPropertyPath(List traitPropertyPath) { + return String.join("/", traitPropertyPath); + } +} diff --git a/smithy-linters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.ValidatorService b/smithy-linters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.ValidatorService index efab94fb003..4d768d18dc7 100644 --- a/smithy-linters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.ValidatorService +++ b/smithy-linters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.ValidatorService @@ -1,5 +1,6 @@ software.amazon.smithy.linters.AbbreviationNameValidator$Provider software.amazon.smithy.linters.CamelCaseValidator$Provider +software.amazon.smithy.linters.NoninclusiveTermsValidator$Provider software.amazon.smithy.linters.InputOutputStructureReuseValidator$Provider software.amazon.smithy.linters.MissingPaginatedTraitValidator$Provider software.amazon.smithy.linters.RepeatedShapeNameValidator$Provider diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.errors b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.errors new file mode 100644 index 00000000000..a2104552b7e --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.errors @@ -0,0 +1,6 @@ +[WARNING] -: ns.foo namespace uses a non-inclusive term `foo`. Consider using one of the following terms instead: `bar` | NoninclusiveTerms +[WARNING] ns.foo#MyMasterService: Service shape uses a non-inclusive term `Master`. Consider using one of the following terms instead: `Main`, `Parent`, `Primary` | NoninclusiveTerms +[WARNING] ns.foo#BlackListThings: Operation shape uses a non-inclusive term `BlackList`. Consider using one of the following terms instead: `DenyList` | NoninclusiveTerms +[WARNING] ns.foo#AInput$foo: Member shape uses a non-inclusive term `foo`. Consider using one of the following terms instead: `bar` | NoninclusiveTerms +[WARNING] ns.foo#AInput$foo: 'documentation' trait has a value that contains a non-inclusive term `apple`. Consider using one of the following terms instead: `banana` | NoninclusiveTerms +[WARNING] ns.foo#BlackListThings: 'documentation' trait has a value that contains a non-inclusive term `replacement`. | NoninclusiveTerms diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.json b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.json new file mode 100644 index 00000000000..864a2bfdc5f --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-append.json @@ -0,0 +1,63 @@ +{ + "smithy": "1.0", + "shapes": { + "ns.foo#MyMasterService": { + "type": "service", + "version": "2021-10-17", + "operations": [ + { + "target": "ns.foo#A" + }, + { + "target": "ns.foo#BlackListThings" + } + ] + }, + "ns.foo#A": { + "type": "operation", + "input": { + "target": "ns.foo#AInput" + }, + "output": { + "target": "ns.foo#AOutput" + }, + "traits": { + "smithy.api#readonly": {} + } + }, + "ns.foo#AInput": { + "type": "structure", + "members": { + "foo": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "These docs are apples!" + } + } + } + }, + "ns.foo#AOutput": { + "type": "structure" + }, + "ns.foo#BlackListThings": { + "type": "operation", + "traits": { + "smithy.api#documentation": "Non-inclusive word with no replacement suggestion." + } + } + }, + "metadata": { + "validators": [ + { + "name": "NoninclusiveTerms", + "configuration": { + "terms": { + "apple": ["banana"], + "foo": ["bar"], + "replacement": [] + } + } + } + ] + } +} diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.errors b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.errors new file mode 100644 index 00000000000..f24b1039527 --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.errors @@ -0,0 +1,3 @@ +[WARNING] -: ns.foo namespace uses a non-inclusive term `foo`. Consider using one of the following terms instead: `bar` | NoninclusiveTerms +[WARNING] ns.foo#AInput$foo: Member shape uses a non-inclusive term `foo`. Consider using one of the following terms instead: `bar` | NoninclusiveTerms +[WARNING] ns.foo#AInput$foo: 'documentation' trait has a value that contains a non-inclusive term `apple`. Consider using one of the following terms instead: `banana` | NoninclusiveTerms diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.json b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.json new file mode 100644 index 00000000000..a6d22f9ffce --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter-override.json @@ -0,0 +1,60 @@ +{ + "smithy": "1.0", + "shapes": { + "ns.foo#MyMasterService": { + "type": "service", + "version": "2021-10-17", + "operations": [ + { + "target": "ns.foo#A" + }, + { + "target": "ns.foo#BlackListThings" + } + ] + }, + "ns.foo#A": { + "type": "operation", + "input": { + "target": "ns.foo#AInput" + }, + "output": { + "target": "ns.foo#AOutput" + }, + "traits": { + "smithy.api#readonly": {} + } + }, + "ns.foo#AInput": { + "type": "structure", + "members": { + "foo": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "These docs are apples!" + } + } + } + }, + "ns.foo#AOutput": { + "type": "structure" + }, + "ns.foo#BlackListThings": { + "type": "operation" + } + }, + "metadata": { + "validators": [ + { + "name": "NoninclusiveTerms", + "configuration": { + "excludeDefaults": true, + "terms": { + "apple": ["banana"], + "foo": ["bar"] + } + } + } + ] + } +} diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.errors b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.errors new file mode 100644 index 00000000000..b9df6a47cb7 --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.errors @@ -0,0 +1,13 @@ +[WARNING] ns.foo#MyMasterService: Service shape uses a non-inclusive term `Master`. Consider using one of the following terms instead: `Main`, `Parent`, `Primary` | NoninclusiveTerms +[WARNING] ns.foo#BlackListThings: Operation shape uses a non-inclusive term `BlackList`. Consider using one of the following terms instead: `DenyList` | NoninclusiveTerms +[WARNING] ns.foo#MyWhitelistTrait: Structure shape uses a non-inclusive term `Whitelist`. Consider using one of the following terms instead: `AllowList` | NoninclusiveTerms +[WARNING] ns.foo#AInput$master_member_name: Member shape uses a non-inclusive term `master`. Consider using one of the following terms instead: `main`, `parent`, `primary` | NoninclusiveTerms +[WARNING] ns.foo#NestedTraitStructure$master_key_violation: Member shape uses a non-inclusive term `master`. Consider using one of the following terms instead: `main`, `parent`, `primary` | NoninclusiveTerms +[WARNING] ns.foo#AInput$foo: 'ns.foo#MySimpleValueTrait' trait has a value that contains a non-inclusive term `slave`. Consider using one of the following terms instead: `child`, `clone`, `replica`, `secondary` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {document/foo/0/bar} contains a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {nested/master_key_violation} contains a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {string_value} contains a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {document/whitelist_doc_key_violation_1} contains a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {collection/2/key} contains a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms +[WARNING] ns.foo#AOutput: 'ns.foo#MyWhitelistTrait' trait value at path {collection/1/blacklist_key} contains a non-inclusive term `blacklist`. Consider using one of the following terms instead: `denyList` | NoninclusiveTerms +[WARNING] ns.foo#MyUnionTrait$int_whitelist: Member shape uses a non-inclusive term `whitelist`. Consider using one of the following terms instead: `allowList` | NoninclusiveTerms diff --git a/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.json b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.json new file mode 100644 index 00000000000..bdaa0871fa6 --- /dev/null +++ b/smithy-linters/src/test/resources/software/amazon/smithy/linters/errorfiles/noninclusive-term-filter.json @@ -0,0 +1,128 @@ +{ + "smithy": "1.0", + "shapes": { + "ns.foo#MyUnionTrait": { + "type": "union", + "members": { + "int_whitelist": { + "target": "smithy.api#Integer" + }, + "no_violation_string": { + "target": "smithy.api#String" + } + } + }, + "ns.foo#MyWhitelistTrait": { + "type": "structure", + "members": { + "document": { + "target": "smithy.api#Document" + }, + "string_value": { + "target": "smithy.api#String" + }, + "nested": { + "target": "ns.foo#NestedTraitStructure" + }, + "collection": { + "target": "ns.foo#ListOfDocs" + }, + "union": { + "target": "ns.foo#MyUnionTrait" + } + }, + "traits": { + "smithy.api#trait": { } + } + }, + "ns.foo#MySimpleValueTrait": { + "type": "string", + "traits": { + "smithy.api#trait": { } + } + }, + "ns.foo#NestedTraitStructure": { + "type": "structure", + "members": { + "master_key_violation": { + "target": "smithy.api#String" + } + } + }, + "ns.foo#ListOfDocs": { + "type": "list", + "member": { + "target": "smithy.api#Document" + } + }, + "ns.foo#MyMasterService": { + "type": "service", + "version": "2021-10-17", + "operations": [ + { + "target": "ns.foo#A" + }, + { + "target": "ns.foo#BlackListThings" + } + ] + }, + "ns.foo#A": { + "type": "operation", + "input": { + "target": "ns.foo#AInput" + }, + "output": { + "target": "ns.foo#AOutput" + }, + "traits": { + "smithy.api#readonly": {} + } + }, + "ns.foo#AInput": { + "type": "structure", + "members": { + "foo": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "These docs are apples!", + "ns.foo#MySimpleValueTrait": "slave_value_in_trait" + } + }, + "master_member_name": { + "target": "smithy.api#String" + } + } + }, + "ns.foo#AOutput": { + "type": "structure", + "traits": { + "ns.foo#MyWhitelistTrait": { + "string_value": "whitelist_value_violation_1", + "nested": { + "master_key_violation": "whitelist_value_violation_2" + }, + "document": { + "foo": [{"bar": "whitelist_value_violation_3"}], + "whitelist_doc_key_violation_1": "safe_value" + }, + "collection": [ + {"free_form_key": "value"}, + {"blacklist_key": "problem"}, + {"key": "problem_whitelist_value"} + ] + } + } + }, + "ns.foo#BlackListThings": { + "type": "operation" + } + }, + "metadata": { + "validators": [ + { + "name": "NoninclusiveTerms" + } + ] + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextIndex.java b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextIndex.java new file mode 100644 index 00000000000..090788b853f --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextIndex.java @@ -0,0 +1,138 @@ +/* + * 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.model.knowledge; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.loader.Prelude; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.ReferencesTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.validation.validators.TraitValueValidator; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Index containing the full set of {@link TextInstance}s associated with a model. + */ +@SmithyUnstableApi +public final class TextIndex implements KnowledgeIndex { + private final List textInstanceList = new ArrayList<>(); + + public TextIndex(Model model) { + Set visitedNamespaces = new HashSet<>(); + // Validating the prelude is a feature for internal-only Smithy development + Node validatePreludeNode = model.getMetadata().get(TraitValueValidator.VALIDATE_PRELUDE); + boolean validatePrelude = validatePreludeNode != null + ? validatePreludeNode.expectBooleanNode().getValue() + : false; + + for (final Shape shape : model.toSet()) { + if (validatePrelude || !Prelude.isPreludeShape(shape)) { + if (visitedNamespaces.add(shape.getId().getNamespace())) { + textInstanceList.add(TextInstance.createNamespaceText(shape.getId().getNamespace())); + } + computeShapeTextInstances(shape, textInstanceList, model); + } + } + } + + public static TextIndex of(Model model) { + return model.getKnowledge(TextIndex.class, TextIndex::new); + } + + public Collection getTextInstances() { + return Collections.unmodifiableList(textInstanceList); + } + + private static void computeShapeTextInstances( + Shape shape, + Collection textInstances, + Model model + ) { + textInstances.add(TextInstance.createShapeInstance(shape)); + + for (Trait trait : shape.getAllTraits().values()) { + Shape traitShape = model.expectShape(trait.toShapeId()); + computeTextInstancesForAppliedTrait(trait.toNode(), trait, shape, textInstances, + new ArrayDeque<>(), model, traitShape); + } + } + + private static void computeTextInstancesForAppliedTrait( + Node node, + Trait trait, + Shape parentShape, + Collection textInstances, + Deque propertyPath, + Model model, + Shape currentTraitPropertyShape + ) { + if (trait.toShapeId().equals(ReferencesTrait.ID)) { + //Skip ReferenceTrait because it is referring to other shape names already being checked + } else if (node.isStringNode()) { + textInstances.add(TextInstance.createTraitInstance( + node.expectStringNode().getValue(), parentShape, trait, propertyPath)); + } else if (node.isObjectNode()) { + ObjectNode objectNode = node.expectObjectNode(); + objectNode.getStringMap().entrySet().forEach(memberEntry -> { + propertyPath.offerLast(memberEntry.getKey()); + Shape memberTypeShape = getChildMemberShapeType(memberEntry.getKey(), + model, currentTraitPropertyShape); + if (memberTypeShape == null) { + //This means the "property" key value isn't modeled in the trait's structure/shape definition + //and this text instance is unique + textInstances.add(TextInstance.createTraitInstance( + memberEntry.getKey(), parentShape, trait, propertyPath)); + } + computeTextInstancesForAppliedTrait(memberEntry.getValue(), trait, parentShape, textInstances, + propertyPath, model, memberTypeShape); + propertyPath.removeLast(); + }); + } else if (node.isArrayNode()) { + int index = 0; + for (Node nodeElement : node.expectArrayNode().getElements()) { + propertyPath.offerLast(Integer.toString(index)); + Shape memberTypeShape = getChildMemberShapeType(null, + model, currentTraitPropertyShape); + computeTextInstancesForAppliedTrait(nodeElement, trait, parentShape, textInstances, + propertyPath, model, memberTypeShape); + propertyPath.removeLast(); + ++index; + } + } + } + + private static Shape getChildMemberShapeType(String memberKey, Model model, Shape fromShape) { + if (fromShape != null) { + for (MemberShape member : fromShape.members()) { + if (member.getMemberName().equals(memberKey)) { + return model.getShape(member.getTarget()).get(); + } + } + } + return null; + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextInstance.java b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextInstance.java new file mode 100644 index 00000000000..9721e1b5a46 --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/TextInstance.java @@ -0,0 +1,124 @@ +/* + * 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.model.knowledge; + +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.utils.ListUtils; + +/** + * Contains information about text that occurs in the Smithy IDL written by the owner, + * inluding location metadata. + */ +public final class TextInstance { + private final TextLocationType locationType; + private final String text; + private final Shape shape; + private final Trait trait; + private final List traitPropertyPath; + + private TextInstance( + final TextLocationType locationType, + final String text, + final Shape shape, + final Trait trait, + final Deque traitPropertyPath + ) { + this.locationType = locationType; + this.text = text; + this.shape = shape; + this.trait = trait; + this.traitPropertyPath = traitPropertyPath != null + ? ListUtils.copyOf(traitPropertyPath) + : ListUtils.of(); + } + + static TextInstance createNamespaceText(String namespace) { + Objects.requireNonNull(namespace, "'namespace' must be specified"); + return new TextInstance(TextLocationType.NAMESPACE, namespace, null, null, null); + } + + static TextInstance createShapeInstance(Shape shape) { + Objects.requireNonNull(shape, "'shape' must be specified"); + return new TextInstance(TextLocationType.SHAPE, shape.getId() + .getMember().orElseGet(() -> shape.getId().getName()), + shape, null, null); + } + + static TextInstance createTraitInstance(String text, Shape shape, Trait trait, Deque traitPath) { + Objects.requireNonNull(trait, "'trait' must be specified"); + Objects.requireNonNull(shape, "'shape' must be specified"); + Objects.requireNonNull(text, "'text' must be specified"); + return new TextInstance(TextLocationType.APPLIED_TRAIT, text, shape, trait, traitPath); + } + + /** + * Retrieves the type of TextLocationType associated with the text. + * + * @return Returns the TextLocationType. + */ + public TextLocationType getLocationType() { + return locationType; + } + + /** + * Retrieves the text content of the TextInstance. + * + * @return Returns the model text. + */ + public String getText() { + return text; + } + + /** + * Gets the shape associated with the text. + * + * @return Returns the shape if the text is associated with one. Otherwise, returns null. + */ + public Shape getShape() { + return shape; + } + + /** + * Gets the trait associated with the text. + * + * @return Returns the trait if the text is associated with one. Otherwise, returns null. + */ + public Trait getTrait() { + return trait; + } + + /** + * Gets the ordered path components within a trait's value the text is associated with. + * + * @return Returns the property path if the text is associated with a trait's value. + */ + public List getTraitPropertyPath() { + return traitPropertyPath; + } + + /** + * Enum type indicating what kind of location in the model associated text appeared in. + */ + public enum TextLocationType { + SHAPE, + APPLIED_TRAIT, + NAMESPACE + } +}