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
+ }
+}