From e922ddd80da0862840990fc02bcf2c3bf5a54a6b Mon Sep 17 00:00:00 2001
From: Michael Dowling <mtdowling@gmail.com>
Date: Sun, 19 Jan 2020 10:16:45 -0800
Subject: [PATCH] Make various improvements geared towards Gradle/CLI

AWS protocol tests:
- Use the Gradle plugin to build the AWS protocol tests.

SmithyBuild:
- Only run parallel projections if there's more than one.

SmithyCli (breaking CLI changes):
- to allow the writing to stderr and stdout to be completely customized
  using a Consumer<String>. This allows the Gradle plugin to log writes
  to stdout rather than rely on the non-thread-safe default behavior of
  intercepting calls.
- Colors nows is used by calling out and err directly on an enum variant
  rather than a static method.
- Adding the --logging parameter to every command and removed the static
  `configureLogging` method.
- Adding `stdout` and `stderr` methods to the Cli. These are now called
  by Colors when writing.
- Enabling disabling ANSI colors is done on Cli and not on Colors now.
  All of the methods used to influence the CLI globally is now all on
  the Cli.
- Logging is only configured when a logging option is passed in. A
  custom logger is used that makes calls to the intercepted stderr
  method.
- Running `build` now shows validation results too.

SmithyModel:
- ValidationEvents are now sorted by filename, line number, column,
  severity, shape ID, message, then finally the event ID.
- Introducing the ValidationEventFormatter interface. There are
  now implementations of the current display (showing the event on
  a single line like CheckStyle), and a contextual formatter that
  shows the line of the source file that has the error.
---
 README.md                                     |   3 +-
 .../guides/building-models/gradle-plugin.rst  |   6 +-
 docs/source/guides/converting-to-openapi.rst  |   2 +-
 smithy-aws-protocol-tests/build.gradle.kts    |   9 +-
 .../ec2-query/empty-input-output.smithy       |   0
 .../ec2-query/input-lists.smithy              |   0
 .../smithy => model}/ec2-query/input.smithy   |   0
 .../smithy => model}/ec2-query/main.smithy    |   0
 .../ec2-query/xml-errors.smithy               |   0
 .../ec2-query/xml-lists.smithy                |   0
 .../ec2-query/xml-structs.smithy              |   0
 .../smithy => model}/json-rpc-1-1/main.json   |   0
 .../query/empty-input-output.smithy           |   0
 .../smithy => model}/query/input-lists.smithy |   0
 .../smithy => model}/query/input-maps.smithy  |   0
 .../smithy => model}/query/input.smithy       |   0
 .../smithy => model}/query/main.smithy        |   0
 .../smithy => model}/query/xml-errors.smithy  |   0
 .../smithy => model}/query/xml-lists.smithy   |   0
 .../smithy => model}/query/xml-maps.smithy    |   0
 .../smithy => model}/query/xml-structs.smithy |   0
 .../rest-json/empty-input-output.smithy       |   0
 .../rest-json/endpoint-host-trait.smithy      |   0
 .../smithy => model}/rest-json/errors.smithy  |   0
 .../rest-json/http-headers.smithy             |   0
 .../rest-json/http-labels.smithy              |   0
 .../rest-json/http-payload.smithy             |   0
 .../rest-json/http-prefix-headers.smithy      |   0
 .../rest-json/http-query.smithy               |   0
 .../rest-json/json-lists.smithy               |   0
 .../rest-json/json-maps.smithy                |   0
 .../rest-json/json-structs.smithy             |   0
 .../smithy => model}/rest-json/main.smithy    |   0
 .../rest-xml/document-lists.smithy            |   0
 .../rest-xml/document-maps.smithy             |   0
 .../rest-xml/document-structs.smithy          |   0
 .../rest-xml/document-xml-attributes.smithy   |   0
 .../rest-xml/empty-input-output.smithy        |   0
 .../rest-xml/endpoint-host-trait.smithy       |   0
 .../smithy => model}/rest-xml/errors.smithy   |   0
 .../rest-xml/http-headers.smithy              |   0
 .../rest-xml/http-labels.smithy               |   0
 .../rest-xml/http-payload.smithy              |   0
 .../rest-xml/http-prefix-headers.smithy       |   0
 .../rest-xml/http-query.smithy                |   0
 .../smithy => model}/rest-xml/main.smithy     |   0
 .../smithy => model}/shared-types.smithy      |   0
 .../main/resources/META-INF/smithy/manifest   |  43 -----
 .../smithy/aws/protocoltests/ModelTest.java   |  19 --
 .../amazon/smithy/build/SmithyBuildImpl.java  |  17 +-
 .../java/software/amazon/smithy/cli/Cli.java  | 179 +++++++++++++-----
 .../software/amazon/smithy/cli/Colors.java    |  49 ++---
 .../software/amazon/smithy/cli/Parser.java    |   4 +-
 .../software/amazon/smithy/cli/SmithyCli.java |  15 +-
 .../smithy/cli/commands/BuildCommand.java     |  27 +--
 .../smithy/cli/commands/DiffCommand.java      |   9 +-
 .../smithy/cli/commands/ValidateCommand.java  |   7 +-
 .../amazon/smithy/cli/commands/Validator.java |  37 ++--
 .../smithy/cli/commands/BuildCommandTest.java |   6 +-
 .../cli/commands/ValidateCommandTest.java     |   8 +-
 .../ContextualValidationEventFormatter.java   | 122 ++++++++++++
 .../LineValidationEventFormatter.java         |  36 ++++
 .../model/validation/ValidationEvent.java     |  38 +++-
 .../validation/ValidationEventFormatter.java  |  29 +++
 ...ontextualValidationEventFormatterTest.java |  59 ++++++
 .../smithy/model/validation/context.smithy    |   9 +
 66 files changed, 504 insertions(+), 229 deletions(-)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/empty-input-output.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/input-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/input.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/main.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/xml-errors.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/xml-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/ec2-query/xml-structs.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/json-rpc-1-1/main.json (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/empty-input-output.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/input-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/input-maps.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/input.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/main.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/xml-errors.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/xml-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/xml-maps.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/query/xml-structs.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/empty-input-output.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/endpoint-host-trait.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/errors.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/http-headers.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/http-labels.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/http-payload.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/http-prefix-headers.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/http-query.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/json-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/json-maps.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/json-structs.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-json/main.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/document-lists.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/document-maps.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/document-structs.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/document-xml-attributes.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/empty-input-output.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/endpoint-host-trait.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/errors.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/http-headers.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/http-labels.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/http-payload.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/http-prefix-headers.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/http-query.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/rest-xml/main.smithy (100%)
 rename smithy-aws-protocol-tests/{src/main/resources/META-INF/smithy => model}/shared-types.smithy (100%)
 delete mode 100644 smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/manifest
 delete mode 100644 smithy-aws-protocol-tests/src/test/java/software/amazon/smithy/aws/protocoltests/ModelTest.java
 create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatter.java
 create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/validation/LineValidationEventFormatter.java
 create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEventFormatter.java
 create mode 100644 smithy-model/src/test/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatterTest.java
 create mode 100644 smithy-model/src/test/resources/software/amazon/smithy/model/validation/context.smithy

diff --git a/README.md b/README.md
index 6763a45513b..d332bfefbcd 100644
--- a/README.md
+++ b/README.md
@@ -57,8 +57,7 @@ Then, apply the Smithy Gradle Plugin in your `build.gradle.kts` file and run
 
 ```kotlin
 plugins {
-   java
-   id("software.amazon.smithy").version("0.4.2")
+   id("software.amazon.smithy").version("0.4.3")
 }
 ```
 
diff --git a/docs/source/guides/building-models/gradle-plugin.rst b/docs/source/guides/building-models/gradle-plugin.rst
index 2b021b31a27..8aedd739a9e 100644
--- a/docs/source/guides/building-models/gradle-plugin.rst
+++ b/docs/source/guides/building-models/gradle-plugin.rst
@@ -21,7 +21,7 @@ The following example configures a project to use the Smithy Gradle plugin:
     .. code-tab:: kotlin
 
         plugins {
-            id("software.amazon.smithy").version("0.4.2")
+            id("software.amazon.smithy").version("0.4.3")
         }
 
 
@@ -138,7 +138,7 @@ The following example ``build.gradle.kts`` will build a Smithy model using a
     .. code-tab:: kotlin
 
         plugins {
-            id("software.amazon.smithy").version("0.4.2")
+            id("software.amazon.smithy").version("0.4.3")
         }
 
         // The SmithyExtension is used to customize the build. This example
@@ -184,7 +184,7 @@ build that uses the "external" projection.
     .. code-tab:: kotlin
 
         plugins {
-            id("software.amazon.smithy").version("0.4.2")
+            id("software.amazon.smithy").version("0.4.3")
         }
 
         buildscript {
diff --git a/docs/source/guides/converting-to-openapi.rst b/docs/source/guides/converting-to-openapi.rst
index 415fc80c2ab..66c61cc397b 100644
--- a/docs/source/guides/converting-to-openapi.rst
+++ b/docs/source/guides/converting-to-openapi.rst
@@ -101,7 +101,7 @@ specification from a Smithy model using a buildscript dependency:
 
     plugins {
         java
-        id("software.amazon.smithy").version("0.4.2")
+        id("software.amazon.smithy").version("0.4.3")
     }
 
     buildscript {
diff --git a/smithy-aws-protocol-tests/build.gradle.kts b/smithy-aws-protocol-tests/build.gradle.kts
index d1a42fcb428..2ab07378e6e 100644
--- a/smithy-aws-protocol-tests/build.gradle.kts
+++ b/smithy-aws-protocol-tests/build.gradle.kts
@@ -17,7 +17,12 @@ description = "Defines protocol tests for AWS HTTP protocols."
 extra["displayName"] = "Smithy :: AWS :: Protocol Tests"
 extra["moduleName"] = "software.amazon.smithy.aws.protocoltests"
 
+plugins {
+    id("software.amazon.smithy").version("0.4.3")
+}
+
 dependencies {
-    api(project(":smithy-protocol-test-traits"))
-    api(project(":smithy-aws-traits"))
+    compile(project(":smithy-cli"))
+    implementation(project(":smithy-protocol-test-traits"))
+    implementation(project(":smithy-aws-traits"))
 }
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/empty-input-output.smithy b/smithy-aws-protocol-tests/model/ec2-query/empty-input-output.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/empty-input-output.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/empty-input-output.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/input-lists.smithy b/smithy-aws-protocol-tests/model/ec2-query/input-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/input-lists.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/input-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/input.smithy b/smithy-aws-protocol-tests/model/ec2-query/input.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/input.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/input.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/main.smithy b/smithy-aws-protocol-tests/model/ec2-query/main.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/main.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/main.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-errors.smithy b/smithy-aws-protocol-tests/model/ec2-query/xml-errors.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-errors.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/xml-errors.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-lists.smithy b/smithy-aws-protocol-tests/model/ec2-query/xml-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-lists.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/xml-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-structs.smithy b/smithy-aws-protocol-tests/model/ec2-query/xml-structs.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/ec2-query/xml-structs.smithy
rename to smithy-aws-protocol-tests/model/ec2-query/xml-structs.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/json-rpc-1-1/main.json b/smithy-aws-protocol-tests/model/json-rpc-1-1/main.json
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/json-rpc-1-1/main.json
rename to smithy-aws-protocol-tests/model/json-rpc-1-1/main.json
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/empty-input-output.smithy b/smithy-aws-protocol-tests/model/query/empty-input-output.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/empty-input-output.smithy
rename to smithy-aws-protocol-tests/model/query/empty-input-output.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input-lists.smithy b/smithy-aws-protocol-tests/model/query/input-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input-lists.smithy
rename to smithy-aws-protocol-tests/model/query/input-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input-maps.smithy b/smithy-aws-protocol-tests/model/query/input-maps.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input-maps.smithy
rename to smithy-aws-protocol-tests/model/query/input-maps.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input.smithy b/smithy-aws-protocol-tests/model/query/input.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/input.smithy
rename to smithy-aws-protocol-tests/model/query/input.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/main.smithy b/smithy-aws-protocol-tests/model/query/main.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/main.smithy
rename to smithy-aws-protocol-tests/model/query/main.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-errors.smithy b/smithy-aws-protocol-tests/model/query/xml-errors.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-errors.smithy
rename to smithy-aws-protocol-tests/model/query/xml-errors.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-lists.smithy b/smithy-aws-protocol-tests/model/query/xml-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-lists.smithy
rename to smithy-aws-protocol-tests/model/query/xml-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-maps.smithy b/smithy-aws-protocol-tests/model/query/xml-maps.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-maps.smithy
rename to smithy-aws-protocol-tests/model/query/xml-maps.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-structs.smithy b/smithy-aws-protocol-tests/model/query/xml-structs.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/query/xml-structs.smithy
rename to smithy-aws-protocol-tests/model/query/xml-structs.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/empty-input-output.smithy b/smithy-aws-protocol-tests/model/rest-json/empty-input-output.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/empty-input-output.smithy
rename to smithy-aws-protocol-tests/model/rest-json/empty-input-output.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/endpoint-host-trait.smithy b/smithy-aws-protocol-tests/model/rest-json/endpoint-host-trait.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/endpoint-host-trait.smithy
rename to smithy-aws-protocol-tests/model/rest-json/endpoint-host-trait.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/errors.smithy b/smithy-aws-protocol-tests/model/rest-json/errors.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/errors.smithy
rename to smithy-aws-protocol-tests/model/rest-json/errors.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-headers.smithy b/smithy-aws-protocol-tests/model/rest-json/http-headers.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-headers.smithy
rename to smithy-aws-protocol-tests/model/rest-json/http-headers.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-labels.smithy b/smithy-aws-protocol-tests/model/rest-json/http-labels.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-labels.smithy
rename to smithy-aws-protocol-tests/model/rest-json/http-labels.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-payload.smithy b/smithy-aws-protocol-tests/model/rest-json/http-payload.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-payload.smithy
rename to smithy-aws-protocol-tests/model/rest-json/http-payload.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-prefix-headers.smithy b/smithy-aws-protocol-tests/model/rest-json/http-prefix-headers.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-prefix-headers.smithy
rename to smithy-aws-protocol-tests/model/rest-json/http-prefix-headers.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-query.smithy b/smithy-aws-protocol-tests/model/rest-json/http-query.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/http-query.smithy
rename to smithy-aws-protocol-tests/model/rest-json/http-query.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-lists.smithy b/smithy-aws-protocol-tests/model/rest-json/json-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-lists.smithy
rename to smithy-aws-protocol-tests/model/rest-json/json-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-maps.smithy b/smithy-aws-protocol-tests/model/rest-json/json-maps.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-maps.smithy
rename to smithy-aws-protocol-tests/model/rest-json/json-maps.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-structs.smithy b/smithy-aws-protocol-tests/model/rest-json/json-structs.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/json-structs.smithy
rename to smithy-aws-protocol-tests/model/rest-json/json-structs.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/main.smithy b/smithy-aws-protocol-tests/model/rest-json/main.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-json/main.smithy
rename to smithy-aws-protocol-tests/model/rest-json/main.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-lists.smithy b/smithy-aws-protocol-tests/model/rest-xml/document-lists.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-lists.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/document-lists.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-maps.smithy b/smithy-aws-protocol-tests/model/rest-xml/document-maps.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-maps.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/document-maps.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-structs.smithy b/smithy-aws-protocol-tests/model/rest-xml/document-structs.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-structs.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/document-structs.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-xml-attributes.smithy b/smithy-aws-protocol-tests/model/rest-xml/document-xml-attributes.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/document-xml-attributes.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/document-xml-attributes.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/empty-input-output.smithy b/smithy-aws-protocol-tests/model/rest-xml/empty-input-output.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/empty-input-output.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/empty-input-output.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/endpoint-host-trait.smithy b/smithy-aws-protocol-tests/model/rest-xml/endpoint-host-trait.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/endpoint-host-trait.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/endpoint-host-trait.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/errors.smithy b/smithy-aws-protocol-tests/model/rest-xml/errors.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/errors.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/errors.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-headers.smithy b/smithy-aws-protocol-tests/model/rest-xml/http-headers.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-headers.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/http-headers.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-labels.smithy b/smithy-aws-protocol-tests/model/rest-xml/http-labels.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-labels.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/http-labels.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-payload.smithy b/smithy-aws-protocol-tests/model/rest-xml/http-payload.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-payload.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/http-payload.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-prefix-headers.smithy b/smithy-aws-protocol-tests/model/rest-xml/http-prefix-headers.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-prefix-headers.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/http-prefix-headers.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-query.smithy b/smithy-aws-protocol-tests/model/rest-xml/http-query.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/http-query.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/http-query.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/main.smithy b/smithy-aws-protocol-tests/model/rest-xml/main.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/rest-xml/main.smithy
rename to smithy-aws-protocol-tests/model/rest-xml/main.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/shared-types.smithy b/smithy-aws-protocol-tests/model/shared-types.smithy
similarity index 100%
rename from smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/shared-types.smithy
rename to smithy-aws-protocol-tests/model/shared-types.smithy
diff --git a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/manifest b/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/manifest
deleted file mode 100644
index 26b4a313d9b..00000000000
--- a/smithy-aws-protocol-tests/src/main/resources/META-INF/smithy/manifest
+++ /dev/null
@@ -1,43 +0,0 @@
-ec2-query/empty-input-output.smithy
-ec2-query/input.smithy
-ec2-query/input-lists.smithy
-ec2-query/main.smithy
-ec2-query/xml-errors.smithy
-ec2-query/xml-lists.smithy
-ec2-query/xml-structs.smithy
-json-rpc-1-1/main.json
-query/empty-input-output.smithy
-query/input.smithy
-query/input-lists.smithy
-query/input-maps.smithy
-query/main.smithy
-query/xml-errors.smithy
-query/xml-lists.smithy
-query/xml-maps.smithy
-query/xml-structs.smithy
-rest-json/empty-input-output.smithy
-rest-json/endpoint-host-trait.smithy
-rest-json/errors.smithy
-rest-json/http-headers.smithy
-rest-json/http-labels.smithy
-rest-json/http-payload.smithy
-rest-json/http-prefix-headers.smithy
-rest-json/http-query.smithy
-rest-json/json-lists.smithy
-rest-json/json-maps.smithy
-rest-json/json-structs.smithy
-rest-json/main.smithy
-rest-xml/document-lists.smithy
-rest-xml/document-maps.smithy
-rest-xml/document-structs.smithy
-rest-xml/document-xml-attributes.smithy
-rest-xml/empty-input-output.smithy
-rest-xml/endpoint-host-trait.smithy
-rest-xml/errors.smithy
-rest-xml/http-headers.smithy
-rest-xml/http-labels.smithy
-rest-xml/http-payload.smithy
-rest-xml/http-prefix-headers.smithy
-rest-xml/http-query.smithy
-rest-xml/main.smithy
-shared-types.smithy
diff --git a/smithy-aws-protocol-tests/src/test/java/software/amazon/smithy/aws/protocoltests/ModelTest.java b/smithy-aws-protocol-tests/src/test/java/software/amazon/smithy/aws/protocoltests/ModelTest.java
deleted file mode 100644
index 0029dc72fbc..00000000000
--- a/smithy-aws-protocol-tests/src/test/java/software/amazon/smithy/aws/protocoltests/ModelTest.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package software.amazon.smithy.aws.protocoltests;
-
-import org.junit.jupiter.api.Test;
-import software.amazon.smithy.model.Model;
-import software.amazon.smithy.model.validation.ValidatedResult;
-
-/**
- * TODO: fix gradle plugin and remove this code.
- */
-public class ModelTest {
-    @Test
-    public void loadsModel() {
-        ValidatedResult<Model> r = Model.assembler()
-                .discoverModels()
-                .assemble();
-        System.out.println(r.getValidationEvents());
-        r.unwrap();
-    }
-}
diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java b/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java
index 31c1754e63c..8b2ea110eff 100644
--- a/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java
+++ b/smithy-build/src/main/java/software/amazon/smithy/build/SmithyBuildImpl.java
@@ -189,14 +189,25 @@ void applyAllProjections(
             } else {
                 parallelProjectionNameOrder.add(name);
                 parallelProjections.add(() -> {
-                    ProjectionResult projectionResult = applyProjection(name, config, resolvedModel);
-                    projectionResultConsumer.accept(projectionResult);
+                    executeSerialProjection(resolvedModel, name, config,
+                                            projectionResultConsumer, projectionExceptionConsumer);
                     return null;
                 });
             }
         }
 
-        if (!parallelProjections.isEmpty()) {
+        // Common case of only executing a single plugin per/projection.
+        if (parallelProjections.size() == 1) {
+            try {
+                parallelProjections.get(0).call();
+            } catch (Throwable e) {
+                if (e instanceof RuntimeException) {
+                    throw (RuntimeException) e;
+                } else {
+                    throw new RuntimeException(e);
+                }
+            }
+        } else if (!parallelProjections.isEmpty()) {
             executeParallelProjections(parallelProjections, parallelProjectionNameOrder, projectionExceptionConsumer);
         }
     }
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/Cli.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/Cli.java
index db7ec8e5b01..98a1693b465 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/Cli.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/Cli.java
@@ -22,10 +22,11 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Consumer;
 import java.util.logging.ConsoleHandler;
+import java.util.logging.Formatter;
 import java.util.logging.Handler;
 import java.util.logging.Level;
-import java.util.logging.LogManager;
 import java.util.logging.LogRecord;
 import java.util.logging.Logger;
 import java.util.logging.SimpleFormatter;
@@ -44,7 +45,7 @@
  *     <li>--no-color: Explicitly disables ANSI colors.</li>
  *     <li>--force-color: Explicitly enables ANSI colors.</li>
  *     <li>--stacktrace: Prints the stacktrace of any CLI exception that is thrown.</li>
- *     <li>--quiet-logs: Disables writing log messages to STDOUT.</li>
+ *     <li>--logging: Sets the log level to one of OFF, SEVERE, WARNING, INFO, FINE, ALL.</li>
  * </ul>
  *
  * <p>Why are we not using a library for this? Because parsing command line
@@ -59,12 +60,19 @@ public final class Cli {
     public static final String FORCE_COLOR = "--force-color";
     public static final String DEBUG = "--debug";
     public static final String STACKTRACE = "--stacktrace";
-    public static final String QUIET_LOGS = "--quiet-logs";
+    public static final String LOGGING = "--logging";
+
+    /** Configures whether or not to use ANSI colors. */
+    static boolean useAnsiColors = isAnsiColorSupported();
+
     private static final SimpleDateFormat FORMAT = new SimpleDateFormat("HH:mm:ss.SSS");
 
+    // Note that we don't use a method reference here in case System.out or System.err are changed.
+    private static Consumer<String> stdout = s -> System.out.println(s);
+    private static Consumer<String> stderr = s -> System.err.println(s);
+
     private final String applicationName;
     private final ClassLoader classLoader;
-    private boolean configureLogging;
     private Map<String, Command> commands = new TreeMap<>();
 
     /**
@@ -96,15 +104,6 @@ public void addCommand(Command command) {
         commands.put(command.getName(), command);
     }
 
-    /**
-     * Calling this method ensures that logging is configured for the CLI.
-     *
-     * @param configureLogging Set to true to configure log formats and levels.
-     */
-    public void configureLogging(boolean configureLogging) {
-        this.configureLogging = configureLogging;
-    }
-
     /**
      * Execute the command line using the given arguments.
      *
@@ -126,17 +125,19 @@ public void run(String[] args) {
                 Command command = commands.get(argument);
                 Parser parser = command.getParser();
                 Arguments parsedArguments = parser.parse(args, 1);
+
                 // Use the --no-color argument to globally disable ANSI colors.
                 if (parsedArguments.has(NO_COLOR)) {
-                    Colors.setUseAnsiColors(false);
+                    setUseAnsiColors(false);
                 } else if (parsedArguments.has(FORCE_COLOR)) {
-                    Colors.setUseAnsiColors(true);
+                    setUseAnsiColors(true);
                 }
+
                 // Automatically handle --help output for subcommands.
                 if (parsedArguments.has(HELP)) {
                     printHelp(command, parser);
                 } else {
-                    configureLogging(args);
+                    configureLogging(parsedArguments);
                     command.execute(parsedArguments, classLoader);
                 }
             } else {
@@ -148,37 +149,96 @@ public void run(String[] args) {
         }
     }
 
-    private void configureLogging(String[] args) {
-        if (configureLogging && !hasArgument(args, QUIET_LOGS)) {
-            Handler handler = getConsoleHandler();
-            if (hasArgument(args, DEBUG)) {
-                handler.setFormatter(new DebugFormatter());
-                handler.setLevel(Level.ALL);
-                // Configure logging level of all loggers.
-                Logger rootLogger = LogManager.getLogManager().getLogger("");
-                rootLogger.setLevel(Level.ALL);
-                for (Handler h : rootLogger.getHandlers()) {
-                    h.setLevel(Level.ALL);
-                }
-            } else {
-                handler.setFormatter(new BasicFormatter());
-                handler.setLevel(Level.WARNING);
-            }
-        }
+    /**
+     * Configures a custom STDOUT printer.
+     *
+     * @param printer Consumer responsible for writing to STDOUT.
+     */
+    public static void setStdout(Consumer<String> printer) {
+        stdout = printer;
+    }
+
+    /**
+     * Configures a custom STDERR printer.
+     *
+     * @param printer Consumer responsible for writing to STDERR.
+     */
+    public static void setStderr(Consumer<String> printer) {
+        stderr = printer;
+    }
+
+    /**
+     * Write a line of text to the configured STDOUT.
+     *
+     * @param message Message to write.
+     */
+    public static void stdout(Object message) {
+        stdout.accept(String.valueOf(message));
+    }
+
+    /**
+     * Write a line of text to the configured STDERR.
+     *
+     * @param message Message to write.
+     */
+    public static void stderr(Object message) {
+        stderr.accept(String.valueOf(message));
     }
 
-    private static Handler getConsoleHandler() {
+    /**
+     * Explicitly configures whether or not to use ANSI colors.
+     *
+     * @param useAnsiColors Set to true or false to enable/disable.
+     */
+    public static void setUseAnsiColors(boolean useAnsiColors) {
+        Cli.useAnsiColors = useAnsiColors;
+    }
+
+    /**
+     * Does a really simple check to see if ANSI colors are supported.
+     *
+     * @return Returns true if ANSI probably works.
+     */
+    private static boolean isAnsiColorSupported() {
+        return System.console() != null && System.getenv().get("TERM") != null;
+    }
+
+    private void configureLogging(Arguments parsedArgs) {
+        boolean configureLogging = parsedArgs.has(DEBUG) || parsedArgs.has(LOGGING);
+
+        if (!configureLogging) {
+            return;
+        }
+
+        Level level = Level.parse(parsedArgs.parameter(LOGGING, Level.ALL.getName()));
+
+        // Remove any currently present console loggers.
         Logger rootLogger = Logger.getLogger("");
+        removeConsoleHandler(rootLogger);
+
+        if (parsedArgs.has(DEBUG)) {
+            // Debug ignores the given logging level and just logs everything.
+            CliLogHandler handler = new CliLogHandler(new DebugFormatter());
+            handler.setLevel(Level.ALL);
+            rootLogger.addHandler(handler);
+            rootLogger.setLevel(Level.ALL);
+            for (Handler h : rootLogger.getHandlers()) {
+                h.setLevel(Level.ALL);
+            }
+        } else if (level != Level.OFF) {
+            CliLogHandler handler = new CliLogHandler(new BasicFormatter());
+            handler.setLevel(level);
+            rootLogger.addHandler(handler);
+        }
+    }
 
+    private static void removeConsoleHandler(Logger rootLogger) {
         for (Handler handler : rootLogger.getHandlers()) {
             if (handler instanceof ConsoleHandler) {
-                return handler;
+                // Remove any console log handlers.
+                rootLogger.removeHandler(handler);
             }
         }
-
-        Handler consoleHandler = new ConsoleHandler();
-        rootLogger.addHandler(consoleHandler);
-        return consoleHandler;
     }
 
     private boolean hasArgument(String[] args, String name) {
@@ -193,27 +253,26 @@ private boolean hasArgument(String[] args, String name) {
 
     private void printException(String[] args, Throwable throwable) {
         if (hasArgument(args, NO_COLOR)) {
-            Colors.setUseAnsiColors(false);
+            setUseAnsiColors(false);
         }
 
-        Colors.out(Colors.BOLD_RED, throwable.getMessage());
+        Colors.BOLD_RED.out(throwable.getMessage());
         if (hasArgument(args, STACKTRACE)) {
             StringWriter sw = new StringWriter();
             throwable.printStackTrace(new PrintWriter(sw));
             String trace = sw.toString();
-            Colors.out(Colors.RED, trace);
+            Colors.RED.out(trace);
         }
     }
 
     private void printMainHelp() {
-        Colors.out(Colors.BRIGHT_WHITE,
-                   String.format("Usage: %s [-h | --help] <command> [<args>]%n", applicationName));
-        System.out.println("commands:");
+        Colors.BRIGHT_WHITE.out(String.format("Usage: %s [-h | --help] <command> [<args>]%n", applicationName));
+        stdout("commands:");
         Map<String, String> table = new LinkedHashMap<>();
         for (Map.Entry<String, Command> entry : commands.entrySet()) {
             table.put("  " + entry.getKey(), entry.getValue().getSummary());
         }
-        System.out.println(createTable(table).trim());
+        stdout(createTable(table).trim());
     }
 
     private String createTable(Map<String, String> table) {
@@ -255,7 +314,7 @@ private void printHelp(Command command, Parser parser) {
 
         // Print the options name if present.
         parser.getPositionalName().ifPresent(name -> example.append(" ").append(name));
-        Colors.out(Colors.BRIGHT_WHITE, example.append("\n").toString());
+        Colors.BRIGHT_WHITE.out(example.append("\n").toString());
 
         // Print the summary of the command.
         StringBuilder body = new StringBuilder();
@@ -288,7 +347,7 @@ private void printHelp(Command command, Parser parser) {
             body.append("\n\n").append(command.getHelp().trim());
         }
 
-        System.out.println(body);
+        stdout(body.toString());
     }
 
     private static final class BasicFormatter extends SimpleFormatter {
@@ -309,4 +368,28 @@ public synchronized String format(LogRecord r) {
                    + r.getMessage() + System.lineSeparator();
         }
     }
+
+    /**
+     * Logs messages to the CLI's redirect stderr.
+     */
+    private static final class CliLogHandler extends Handler {
+        private final Formatter formatter;
+
+        CliLogHandler(Formatter formatter) {
+            this.formatter = formatter;
+        }
+
+        @Override
+        public void publish(LogRecord record) {
+            if (isLoggable(record)) {
+                Cli.stderr(formatter.format(record));
+            }
+        }
+
+        @Override
+        public void flush() {}
+
+        @Override
+        public void close() {}
+    }
 }
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/Colors.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/Colors.java
index 39c3e2f56a9..591fd51b2c3 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/Colors.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/Colors.java
@@ -41,9 +41,6 @@ public enum Colors {
     BRIGHT_CYAN(96),
     BRIGHT_WHITE(97);
 
-    /** Configures whether or not to use ANSI colors. */
-    private static boolean useAnsiColors = useAnsi();
-
     private int escape;
     private boolean bold;
 
@@ -57,53 +54,33 @@ public enum Colors {
     }
 
     /**
-     * Explicitly configures whether or not to use ANSI colors.
-     *
-     * @param useAnsiColors Set to true or false to enable/disable.
-     */
-    public static void setUseAnsiColors(boolean useAnsiColors) {
-        Colors.useAnsiColors = useAnsiColors;
-    }
-
-    /**
-     * Does a really simple check to see if ANSI colors are supported.
-     *
-     * @return Returns true if ANSI probably works.
-     */
-    private static boolean useAnsi() {
-        return System.console() != null && System.getenv().get("TERM") != null;
-    }
-
-    /**
-     * Prints to stdout using the provided color if ANSI colors are enabled.
+     * Prints to stdout using the Color if ANSI colors are enabled.
      *
-     * @param color ANSI color to print with.
      * @param message Message to print.
      */
-    public static void out(Colors color, String message) {
-        if (useAnsiColors) {
-            System.out.println(format(color, message));
+    public void out(String message) {
+        if (Cli.useAnsiColors) {
+            Cli.stdout(format(message));
         } else {
-            System.out.println(message);
+            Cli.stdout(message);
         }
     }
 
     /**
-     * Prints to stderr using the provided color if ANSI colors are enabled.
+     * Prints to stderr using the Color if ANSI colors are enabled.
      *
-     * @param color ANSI color to print with.
      * @param message Message to print.
      */
-    public static void err(Colors color, String message) {
-        if (useAnsiColors) {
-            System.err.println(format(color, message));
+    public void err(String message) {
+        if (Cli.useAnsiColors) {
+            Cli.stderr(format(message));
         } else {
-            System.err.println(message);
+            Cli.stderr(message);
         }
     }
 
-    private static String format(Colors color, String message) {
-        String colored = String.format("\u001b[%dm%s\u001b[0m", color.escape, message);
-        return color.bold ? String.format("\033[1m%s\033[0m", colored) : colored;
+    private String format(String message) {
+        String colored = String.format("\u001b[%dm%s\u001b[0m", escape, message);
+        return bold ? String.format("\033[1m%s\033[0m", colored) : colored;
     }
 }
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/Parser.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/Parser.java
index b1e396639c1..d8545a02edd 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/Parser.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/Parser.java
@@ -245,13 +245,13 @@ public static final class Builder implements SmithyBuilder<Parser> {
         private List<Argument> arguments = new ArrayList<>();
 
         private Builder() {
-            // Always include --help, --debug, --stacktrace, and --no-color options.
+            // Always include --help, --debug, --stacktrace, and --no-color options; and --logging X.
             option(Cli.HELP, "-h", "Print this help");
             option(Cli.DEBUG, "Display debug information");
             option(Cli.STACKTRACE, "Display a stacktrace on error");
             option(Cli.NO_COLOR, "Explicitly disable ANSI colors");
             option(Cli.FORCE_COLOR, "Explicitly enables ANSI colors");
-            option(Cli.QUIET_LOGS, "Disables writing log messages to STDOUT");
+            parameter(Cli.LOGGING, "Sets the log level to one of OFF, SEVERE, WARNING, INFO, FINE, ALL");
         }
 
         @Override
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/SmithyCli.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/SmithyCli.java
index 184bf3f28f7..cfb49a34c00 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/SmithyCli.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/SmithyCli.java
@@ -29,7 +29,6 @@ public final class SmithyCli {
     public static final String ALLOW_UNKNOWN_TRAITS = "--allow-unknown-traits";
 
     private ClassLoader classLoader = getClass().getClassLoader();
-    private boolean configureLogging;
 
     private SmithyCli() {}
 
@@ -49,7 +48,7 @@ public static SmithyCli create() {
      */
     public static void main(String... args) {
         try {
-            SmithyCli.create().configureLogging(true).run(args);
+            SmithyCli.create().run(args);
         } catch (CliError e) {
             System.exit(e.code);
         } catch (Exception e) {
@@ -68,17 +67,6 @@ public SmithyCli classLoader(ClassLoader classLoader) {
         return this;
     }
 
-    /**
-     * Configures the CLI to modify the JUL log level and format.
-     *
-     * @param configureLogging Set to true to modify log formats and levels.
-     * @return Returns the CLI.
-     */
-    public SmithyCli configureLogging(boolean configureLogging) {
-        this.configureLogging = configureLogging;
-        return this;
-    }
-
     /**
      * Runs the CLI using a list of arguments.
      *
@@ -95,7 +83,6 @@ public void run(List<String> args) {
      */
     public void run(String... args) {
         Cli cli = new Cli("smithy", classLoader);
-        cli.configureLogging(configureLogging);
         cli.addCommand(new ValidateCommand());
         cli.addCommand(new BuildCommand());
         cli.addCommand(new DiffCommand());
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
index 4acf84f6ca9..7e41e223d9d 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/BuildCommand.java
@@ -32,6 +32,7 @@
 import software.amazon.smithy.build.SmithyBuild;
 import software.amazon.smithy.build.model.SmithyBuildConfig;
 import software.amazon.smithy.cli.Arguments;
+import software.amazon.smithy.cli.Cli;
 import software.amazon.smithy.cli.CliError;
 import software.amazon.smithy.cli.Colors;
 import software.amazon.smithy.cli.Command;
@@ -76,7 +77,7 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         String output = arguments.parameter("--output", null);
         List<String> models = arguments.positionalArguments();
 
-        LOGGER.info(String.format("Building Smithy model sources: %s", models));
+        Cli.stdout(String.format("Building Smithy model sources: %s", models));
         SmithyBuildConfig.Builder configBuilder = SmithyBuildConfig.builder();
 
         // Try to find a smithy-build.json file.
@@ -85,7 +86,7 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         }
 
         if (config != null) {
-            LOGGER.info(String.format("Loading Smithy configs: [%s]", String.join(" ", config)));
+            Cli.stdout(String.format("Loading Smithy configs: [%s]", String.join(" ", config)));
             config.forEach(file -> configBuilder.load(Paths.get(file)));
         }
 
@@ -93,7 +94,7 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
             configBuilder.outputDirectory(output);
             try {
                 Files.createDirectories(Paths.get(output));
-                LOGGER.fine(String.format("Output directory set to: %s", output));
+                LOGGER.info(String.format("Output directory set to: %s", output));
             } catch (IOException e) {
                 throw new CliError("Unable to create Smithy output directory: " + e.getMessage());
             }
@@ -102,8 +103,8 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         SmithyBuildConfig smithyBuildConfig = configBuilder.build();
 
         // Build the model and fail if there are errors.
-        ValidatedResult<Model> sourceResult = buildModel(classLoader, models, arguments);
-        Model model = sourceResult.unwrap();
+        Model model = buildModel(classLoader, models, arguments);
+
         SmithyBuild smithyBuild = SmithyBuild.create(classLoader)
                 .config(smithyBuildConfig)
                 .model(model);
@@ -126,7 +127,7 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         Colors color = resultConsumer.failedProjections.isEmpty()
                 ? Colors.BRIGHT_BOLD_GREEN
                 : Colors.BRIGHT_BOLD_YELLOW;
-        Colors.out(color, String.format(
+        color.out(String.format(
                 "Smithy built %s projection(s), %s plugin(s), and %s artifacts",
                 resultConsumer.projectionCount,
                 resultConsumer.pluginCount,
@@ -142,14 +143,14 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         }
     }
 
-    private ValidatedResult<Model> buildModel(ClassLoader classLoader, List<String> models, Arguments arguments) {
+    private Model buildModel(ClassLoader classLoader, List<String> models, Arguments arguments) {
         ModelAssembler assembler = CommandUtils.createModelAssembler(classLoader);
         CommandUtils.handleModelDiscovery(arguments, assembler, classLoader);
         CommandUtils.handleUnknownTraitsOption(arguments, assembler);
         models.forEach(assembler::addImport);
         ValidatedResult<Model> result = assembler.assemble();
-        Validator.validate(result, true);
-        return result;
+        Validator.validate(result);
+        return result.getResult().orElseThrow(() -> new RuntimeException("No result; expected Validator to throw"));
     }
 
     private static final class ResultConsumer implements Consumer<ProjectionResult>, BiConsumer<String, Throwable> {
@@ -168,7 +169,7 @@ public void accept(String name, Throwable exception) {
                 message.append(element).append(System.lineSeparator());
             }
 
-            System.out.println(message);
+            Cli.stdout(message);
         }
 
         @Override
@@ -185,7 +186,7 @@ public void accept(ProjectionResult result) {
                         message.append(event).append(System.lineSeparator());
                     }
                 });
-                Colors.out(Colors.RED, message.toString());
+                Colors.RED.out(message.toString());
             } else {
                 // Only increment the projection count if it succeeded.
                 projectionCount.incrementAndGet();
@@ -196,7 +197,9 @@ public void accept(ProjectionResult result) {
             // Get the base directory of the projection.
             Iterator<FileManifest> manifestIterator = result.getPluginManifests().values().iterator();
             Path root = manifestIterator.hasNext() ? manifestIterator.next().getBaseDir().getParent() : null;
-            Colors.out(Colors.GREEN, String.format("Completed projection %s: %s", result.getProjectionName(), root));
+            Colors.GREEN.out(String.format(
+                    "Completed projection %s (%d shapes): %s",
+                    result.getProjectionName(), result.getModel().toSet().size(), root));
 
             // Increment the total number of artifacts written.
             for (FileManifest manifest : result.getPluginManifests().values()) {
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/DiffCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/DiffCommand.java
index 7acb37d3ae0..d1736bca25a 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/DiffCommand.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/DiffCommand.java
@@ -19,6 +19,7 @@
 import java.util.logging.Logger;
 import java.util.stream.Collectors;
 import software.amazon.smithy.cli.Arguments;
+import software.amazon.smithy.cli.Cli;
 import software.amazon.smithy.cli.CliError;
 import software.amazon.smithy.cli.Colors;
 import software.amazon.smithy.cli.Command;
@@ -74,15 +75,15 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         }
 
         if (!result.isEmpty()) {
-            System.out.println(result);
+            Cli.stdout(result);
         }
 
         if (hasDanger) {
-            Colors.out(Colors.BRIGHT_BOLD_RED, "Smithy diff detected danger");
+            Colors.BRIGHT_BOLD_RED.out("Smithy diff detected danger");
         } else if (hasWarning) {
-            Colors.out(Colors.BRIGHT_BOLD_YELLOW, "Smithy diff complete with warnings");
+            Colors.BRIGHT_BOLD_YELLOW.out("Smithy diff complete with warnings");
         } else {
-            Colors.out(Colors.BRIGHT_BOLD_GREEN, "Smithy diff complete");
+            Colors.BRIGHT_BOLD_GREEN.out("Smithy diff complete");
         }
     }
 
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ValidateCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ValidateCommand.java
index 6bda03135d7..2712a742a23 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ValidateCommand.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ValidateCommand.java
@@ -16,7 +16,6 @@
 package software.amazon.smithy.cli.commands;
 
 import java.util.List;
-import java.util.logging.Logger;
 import software.amazon.smithy.cli.Arguments;
 import software.amazon.smithy.cli.Colors;
 import software.amazon.smithy.cli.Command;
@@ -27,8 +26,6 @@
 import software.amazon.smithy.model.validation.ValidatedResult;
 
 public final class ValidateCommand implements Command {
-    private static final Logger LOGGER = Logger.getLogger(ValidateCommand.class.getName());
-
     @Override
     public String getName() {
         return "validate";
@@ -52,7 +49,7 @@ public Parser getParser() {
     @Override
     public void execute(Arguments arguments, ClassLoader classLoader) {
         List<String> models = arguments.positionalArguments();
-        LOGGER.info(String.format("Validating Smithy model sources: %s", models));
+        Colors.BRIGHT_WHITE.out(String.format("Validating Smithy model sources: %s", models));
 
         ModelAssembler assembler = CommandUtils.createModelAssembler(classLoader);
         CommandUtils.handleModelDiscovery(arguments, assembler, classLoader);
@@ -61,6 +58,6 @@ public void execute(Arguments arguments, ClassLoader classLoader) {
         models.forEach(assembler::addImport);
         ValidatedResult<Model> modelResult = assembler.assemble();
         Validator.validate(modelResult);
-        Colors.out(Colors.BRIGHT_BOLD_GREEN, "Smithy validation complete");
+        Colors.BRIGHT_BOLD_GREEN.out("Smithy validation complete");
     }
 }
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/Validator.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/Validator.java
index acd92857b30..ae4b355594b 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/Validator.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/Validator.java
@@ -17,14 +17,13 @@
 
 import static java.lang.String.format;
 
-import java.util.Collections;
-import java.util.Comparator;
+import software.amazon.smithy.cli.Cli;
 import software.amazon.smithy.cli.CliError;
 import software.amazon.smithy.cli.Colors;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.validation.ContextualValidationEventFormatter;
 import software.amazon.smithy.model.validation.Severity;
 import software.amazon.smithy.model.validation.ValidatedResult;
-import software.amazon.smithy.model.validation.ValidationEvent;
 
 /**
  * Shares logic for validating a model and printing out events.
@@ -33,38 +32,34 @@ final class Validator {
     private Validator() {}
 
     static void validate(ValidatedResult<Model> result) {
-        validate(result, false);
-    }
+        ContextualValidationEventFormatter formatter = new ContextualValidationEventFormatter();
 
-    static void validate(ValidatedResult<Model> result, boolean quiet) {
         result.getValidationEvents().stream()
                 .filter(event -> event.getSeverity() != Severity.SUPPRESSED)
-                .sorted(Comparator.comparing(ValidationEvent::toString))
+                .sorted()
                 .forEach(event -> {
                     if (event.getSeverity() == Severity.WARNING) {
-                        Colors.out(Colors.YELLOW, event.toString());
+                        Colors.YELLOW.out(formatter.format(event));
                     } else if (event.getSeverity() == Severity.DANGER || event.getSeverity() == Severity.ERROR) {
-                        Colors.out(Colors.RED, event.toString());
+                        Colors.RED.out(formatter.format(event));
                     } else {
-                        System.out.println(event);
+                        Cli.stdout(event);
                     }
+                    Cli.stdout("");
                 });
 
         long errors = result.getValidationEvents(Severity.ERROR).size();
         long dangers = result.getValidationEvents(Severity.DANGER).size();
 
-        if (!quiet) {
-            String line = format(
-                    "Validation result: %s ERROR(s), %d DANGER(s), %d WARNING(s), %d NOTE(s)",
-                    errors, dangers, result.getValidationEvents(Severity.WARNING).size(),
-                    result.getValidationEvents(Severity.NOTE).size());
-            System.out.println(String.join("", Collections.nCopies(line.length(), "-")));
-            System.out.println(line);
-            result.getResult().ifPresent(model -> System.out.println(String.format(
-                    "Validated %d shapes in model", model.shapes().count())));
-        }
+        String line = format(
+                "Validation result: %s ERROR(s), %d DANGER(s), %d WARNING(s), %d NOTE(s)",
+                errors, dangers, result.getValidationEvents(Severity.WARNING).size(),
+                result.getValidationEvents(Severity.NOTE).size());
+        Cli.stdout(line);
+        result.getResult().ifPresent(model -> Cli.stdout(String.format(
+                "Validated %d shapes in model", model.shapes().count())));
 
-        if (errors + dangers > 0) {
+        if (!result.getResult().isPresent() || errors + dangers > 0) {
             // Show the error and danger severity events.
             throw new CliError(format("The model is invalid: %s ERROR(s), %d DANGER(s)", errors, dangers));
         }
diff --git a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/BuildCommandTest.java b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/BuildCommandTest.java
index de8e333ba3a..2fd27f199ae 100644
--- a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/BuildCommandTest.java
+++ b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/BuildCommandTest.java
@@ -48,7 +48,7 @@ public void dumpsOutValidationErrorsAndFails() throws Exception {
 
         CliError e = Assertions.assertThrows(CliError.class, () -> {
             String model = getClass().getResource("unknown-trait.smithy").getPath();
-            SmithyCli.create().configureLogging(true).run("build", model);
+            SmithyCli.create().run("build", model);
         });
 
         System.setOut(out);
@@ -67,7 +67,7 @@ public void printsSuccessfulProjections() throws Exception {
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
         PrintStream printStream = new PrintStream(outputStream);
         System.setOut(printStream);
-        SmithyCli.create().configureLogging(true).run("build", model);
+        SmithyCli.create().run("build", model);
         System.setOut(out);
         String output = outputStream.toString("UTF-8");
 
@@ -85,7 +85,7 @@ public void validationFailuresCausedByProjectionsAreDetected() throws Exception
         CliError e = Assertions.assertThrows(CliError.class, () -> {
             String model = getClass().getResource("valid-model.smithy").getPath();
             String config = getClass().getResource("projection-build-failure.json").getPath();
-            SmithyCli.create().configureLogging(true).run("build", "--debug", "--config", config, model);
+            SmithyCli.create().run("build", "--debug", "--config", config, model);
         });
 
         System.setOut(out);
diff --git a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ValidateCommandTest.java b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ValidateCommandTest.java
index a402a58ed3d..b2681d85441 100644
--- a/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ValidateCommandTest.java
+++ b/smithy-cli/src/test/java/software/amazon/smithy/cli/commands/ValidateCommandTest.java
@@ -42,14 +42,14 @@ public void hasValidateCommand() throws Exception {
     @Test
     public void usesModelDiscoveryWithCustomValidClasspath() {
         String dir = getClass().getResource("valid.jar").getPath();
-        SmithyCli.create().configureLogging(true).run("validate", "--debug", "--discover-classpath", dir);
+        SmithyCli.create().run("validate", "--debug", "--discover-classpath", dir);
     }
 
     @Test
     public void usesModelDiscoveryWithCustomInvalidClasspath() {
         CliError e = Assertions.assertThrows(CliError.class, () -> {
             String dir = getClass().getResource("invalid.jar").getPath();
-            SmithyCli.create().configureLogging(true).run("validate", "--debug", "--discover-classpath", dir);
+            SmithyCli.create().run("validate", "--debug", "--discover-classpath", dir);
         });
 
         assertThat(e.getMessage(), containsString("1 ERROR(s)"));
@@ -59,7 +59,7 @@ public void usesModelDiscoveryWithCustomInvalidClasspath() {
     public void failsOnUnknownTrait() {
         CliError e = Assertions.assertThrows(CliError.class, () -> {
             String model = getClass().getResource("unknown-trait.smithy").getPath();
-            SmithyCli.create().configureLogging(true).run("validate", model);
+            SmithyCli.create().run("validate", model);
         });
 
         assertThat(e.getMessage(), containsString("1 ERROR(s)"));
@@ -68,6 +68,6 @@ public void failsOnUnknownTrait() {
     @Test
     public void allowsUnknownTrait() {
         String model = getClass().getResource("unknown-trait.smithy").getPath();
-        SmithyCli.create().configureLogging(true).run("validate", "--allow-unknown-traits", model);
+        SmithyCli.create().run("validate", "--allow-unknown-traits", model);
     }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatter.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatter.java
new file mode 100644
index 00000000000..c17df7d5b69
--- /dev/null
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatter.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2019 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.validation;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringWriter;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.Formatter;
+import software.amazon.smithy.model.SourceLocation;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * This validation event formatter outputs a validation event that points
+ * to the source code line that triggered the event.
+ *
+ * <p>If the event does not have a source location, then this formatter
+ * will not attempt to load the contents of the model.
+ *
+ * <p>This formatter outputs messages similar to the following text:</p>
+ *
+ * <pre>{@code
+ * ERROR: aws.protocols.tests.ec2#IgnoresWrappingXmlName (Model)
+ *     --> /foo/bar.smithy
+ *      |
+ *  403 | apply MyShape @httpResponseTests([
+ *      |                                  ^
+ *      = Unable to resolve trait `smithy.test#httpResponseTests`. If this is a custom trait, [...]
+ * }</pre>
+ */
+public final class ContextualValidationEventFormatter implements ValidationEventFormatter {
+    @Override
+    public String format(ValidationEvent event) {
+        StringWriter writer = new StringWriter();
+        Formatter formatter = new Formatter(writer);
+        formatter.format("%s: %s (%s)%n",
+                         event.getSeverity(),
+                         event.getShapeId().map(ShapeId::toString).orElse("-"),
+                         event.getEventId());
+
+        if (event.getSourceLocation() != SourceLocation.NONE) {
+            String humanReadableFilename = getHumanReadableFilename(event.getSourceLocation());
+            String contextualLine = null;
+            try {
+                contextualLine = loadContextualLine(event.getSourceLocation());
+            } catch (IOException e) {
+                // Do nothing.
+            }
+
+            if (contextualLine == null) {
+                formatter.format("     @ %s%n", event.getSourceLocation());
+            } else {
+                // Show the filename.
+                formatter.format("     @ %s%n", humanReadableFilename);
+                formatter.format("     |%n");
+                // Show the line number and source code line.
+                formatter.format("%4d | %s%n", event.getSourceLocation().getLine(), contextualLine);
+                // Add a carat to point to the column of the error.
+                formatter.format("     | %" + event.getSourceLocation().getColumn() + "s%n", "^");
+            }
+        }
+
+        // Add the message and indent each line.
+        formatter.format("     = %s%n", event.getMessage().replace("\n", "       \n"));
+
+        // Close up the formatter.
+        formatter.flush();
+
+        return writer.toString();
+    }
+
+    // Filenames might start with a leading file:/. Strip that.
+    private String getHumanReadableFilename(SourceLocation source) {
+        String filename = source.getFilename();
+
+        if (filename.startsWith("file:")) {
+            filename = filename.substring(5);
+        }
+
+        return filename;
+    }
+
+    // Attempts to load a specific line from the model.
+    private String loadContextualLine(SourceLocation source) throws IOException {
+        // Ensure that there's a scheme.
+        String normalizedFile = source.getFilename();
+        if (!source.getFilename().startsWith("file:") && !source.getFilename().startsWith("jar:")) {
+            normalizedFile = "file:" + normalizedFile;
+        }
+
+        // Loading from a JAR needs special treatment, but this can
+        // all actually be handled in a uniform way using URLs.
+        URL url = new URL(normalizedFile);
+        URLConnection connection = url.openConnection();
+        connection.setUseCaches(false);
+
+        try (InputStream input = connection.getInputStream();
+             BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
+            return reader.lines()
+                    .skip(source.getLine() - 1)
+                    .findFirst()
+                    .orElse(null);
+        }
+    }
+}
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/LineValidationEventFormatter.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/LineValidationEventFormatter.java
new file mode 100644
index 00000000000..8251495e5b1
--- /dev/null
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/LineValidationEventFormatter.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2019 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.validation;
+
+import software.amazon.smithy.model.shapes.ShapeId;
+
+/**
+ * Writes {@code ValidationEvent} objects as a single line string.
+ */
+public final class LineValidationEventFormatter implements ValidationEventFormatter {
+    @Override
+    public String format(ValidationEvent event) {
+        return String.format(
+                "[%s] %s: %s | %s %s:%s:%s",
+                event.getSeverity(),
+                event.getShapeId().map(ShapeId::toString).orElse("-"),
+                event.getMessage(),
+                event.getEventId(),
+                event.getSourceLocation().getFilename(),
+                event.getSourceLocation().getLine(),
+                event.getSourceLocation().getColumn());
+    }
+}
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEvent.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEvent.java
index 9058a202d65..d3e4d6ed7af 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEvent.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEvent.java
@@ -38,14 +38,15 @@
  * Events with a severity less than ERROR can be suppressed. All events contain
  * a message, severity, and eventId.
  */
-public final class ValidationEvent implements ToNode, ToSmithyBuilder<ValidationEvent> {
+public final class ValidationEvent implements Comparable<ValidationEvent>, ToNode, ToSmithyBuilder<ValidationEvent> {
+    private static final ValidationEventFormatter DEFAULT_FORMATTER = new LineValidationEventFormatter();
     private final SourceLocation sourceLocation;
     private final String message;
     private final String eventId;
     private final Severity severity;
     private final ShapeId shapeId;
     private final String suppressionReason;
-    private final String asString;
+    private int hash;
 
     private ValidationEvent(Builder builder) {
         if (builder.suppressionReason != null && builder.severity != Severity.SUPPRESSED) {
@@ -58,9 +59,6 @@ private ValidationEvent(Builder builder) {
         this.eventId = SmithyBuilder.requiredState("eventId", builder.eventId);
         this.shapeId = builder.shapeId;
         this.suppressionReason = builder.suppressionReason;
-        this.asString = String.format("[%s] %s: %s | %s %s:%s:%s",
-                severity, shapeId != null ? shapeId : "-", message, eventId,
-                sourceLocation.getFilename(), sourceLocation.getLine(), sourceLocation.getColumn());
     }
 
     public static Builder builder() {
@@ -94,6 +92,27 @@ public static ValidationEvent fromSourceException(SourceException exception, Str
                 .build();
     }
 
+    @Override
+    public int compareTo(ValidationEvent other) {
+        int comparison = getSourceLocation().getFilename().compareTo(other.getSourceLocation().getFilename());
+        if (comparison != 0) {
+            return comparison;
+        }
+
+        comparison = Integer.compare(getSourceLocation().getLine(), other.getSourceLocation().getLine());
+        if (comparison != 0) {
+            return comparison;
+        }
+
+        comparison = Integer.compare(getSourceLocation().getColumn(), other.getSourceLocation().getColumn());
+        if (comparison != 0) {
+            return comparison;
+        }
+
+        // Fall back to a comparison that favors by severity, followed, by shape ID, etc...
+        return toString().compareTo(other.toString());
+    }
+
     @Override
     public Builder toBuilder() {
         Builder builder = new Builder();
@@ -125,12 +144,17 @@ && getShapeId().equals(other.getShapeId())
 
     @Override
     public int hashCode() {
-        return asString.hashCode() + getSuppressionReason().hashCode();
+        int result = hash;
+        if (result == 0) {
+            result = Objects.hash(eventId, shapeId, severity, sourceLocation, message, suppressionReason);
+            hash = result;
+        }
+        return result;
     }
 
     @Override
     public String toString() {
-        return asString;
+        return DEFAULT_FORMATTER.format(this);
     }
 
     @Override
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEventFormatter.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEventFormatter.java
new file mode 100644
index 00000000000..03d305f6ea3
--- /dev/null
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/ValidationEventFormatter.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2019 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.validation;
+
+/**
+ * Formats {@code ValidationEvent}s.
+ */
+public interface ValidationEventFormatter {
+    /**
+     * Converts the event to a string.
+     *
+     * @param event Event to write as a string.
+     * @return Returns the event as a formatted string.
+     */
+    String format(ValidationEvent event);
+}
diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatterTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatterTest.java
new file mode 100644
index 00000000000..73b74633dfd
--- /dev/null
+++ b/smithy-model/src/test/java/software/amazon/smithy/model/validation/ContextualValidationEventFormatterTest.java
@@ -0,0 +1,59 @@
+package software.amazon.smithy.model.validation;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.startsWith;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.SourceLocation;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+public class ContextualValidationEventFormatterTest {
+    @Test
+    public void loadsContext() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("context.smithy"))
+                .assemble()
+                .unwrap();
+
+        Shape shape = model.expectShape(ShapeId.from("example.smithy#Foo"));
+        ValidationEvent event = ValidationEvent.builder()
+                .eventId("foo")
+                .severity(Severity.ERROR)
+                .message("This is the message")
+                .shape(shape)
+                .build();
+
+        String format = new ContextualValidationEventFormatter().format(event);
+
+        assertThat(format, startsWith("ERROR: example.smithy#Foo (foo)"));
+        assertThat(format, containsString("\n     @ "));
+        assertThat(format, endsWith(
+                "\n     |"
+                + "\n   3 | structure Foo {"
+                + "\n     | ^"
+                + "\n     = This is the message"
+                + "\n"));
+    }
+
+    @Test
+    public void doesNotLoadSourceLocationNone() {
+        ValidationEvent event = ValidationEvent.builder()
+                .eventId("foo")
+                .severity(Severity.ERROR)
+                .message("This is the message")
+                .sourceLocation(SourceLocation.NONE)
+                .build();
+
+        String format = new ContextualValidationEventFormatter().format(event);
+
+        assertThat(format, equalTo(
+                "ERROR: - (foo)"
+                + "\n     = This is the message"
+                + "\n"));
+    }
+}
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/validation/context.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/validation/context.smithy
new file mode 100644
index 00000000000..22aea1bf31f
--- /dev/null
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/validation/context.smithy
@@ -0,0 +1,9 @@
+namespace example.smithy
+
+structure Foo {
+  bar: String,
+}
+
+structure Baz {
+  bam: String,
+}