Repository: smithy-lang/smithy
Pull request #1939 (2023-08-22 00:52:22)

Use hard line breaks for some properties
Includes a restructuring of smithy-syntax since it was getting unwieldy.

Now `operations`, `collectionOperations`, `resources`, `errors`, and `rename` always appears on multiple lines if not empty.

```
service Foo {
    operations: [
        PutFoo
        DeleteFoo
    ]
}
```

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedMetadata.java b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedMetadata.java
index 27a6e03c2..6bd365f14 100644
--- a/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedMetadata.java
+++ b/smithy-diff/src/main/java/software/amazon/smithy/diff/evaluators/RemovedMetadata.java
@@ -30,7 +30,7 @@ public final class RemovedMetadata extends AbstractDiffEvaluator {
     public List<ValidationEvent> evaluate(Differences differences) {
         return differences.removedMetadata()
                 .map(metadata -> ValidationEvent.builder()
-                        .id(getEventId())
+                        .id(getEventId() + "." + metadata.getLeft())
                         .severity(Severity.DANGER)
                         .sourceLocation(metadata.getRight().getSourceLocation())
                         .message(String.format(
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/BracketFormatter.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/BracketFormatter.java
new file mode 100644
index 000000000..dfb992fdd
--- /dev/null
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/BracketFormatter.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 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.syntax;
+
+import com.opencastsoftware.prettier4j.Doc;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import software.amazon.smithy.utils.SmithyBuilder;
+
+/**
+ * Formats various kinds of brackets, dealing with interspersed whitespace and comments.
+ */
+final class BracketFormatter {
+
+    private Doc open = Formatter.LBRACE;
+    private Doc close = Formatter.RBRACE;
+    private Collection<Doc> children;
+    private boolean forceLineBreaks;
+
+    static Function<TreeCursor, Stream<Doc>> extractor(
+            Function<TreeCursor, Doc> visitor,
+            Function<TreeCursor, Stream<TreeCursor>> mapper
+    ) {
+        return new Extractor(visitor, mapper);
+    }
+
+    // Brackets children of childType between open and closed brackets. If the children can fit together
+    // on a single line, they are comma separated. If not, they are split onto multiple lines with no commas.
+    static Function<TreeCursor, Stream<Doc>> extractByType(
+            TreeType childType,
+            Function<TreeCursor, Doc> visitor
+    ) {
+        return extractor(visitor, child -> child.getTree().getType() == childType
+                                           ? Stream.of(child)
+                                           : Stream.empty());
+    }
+
+    BracketFormatter open(Doc open) {
+        this.open = open;
+        return this;
+    }
+
+    BracketFormatter close(Doc close) {
+        this.close = close;
+        return this;
+    }
+
+    BracketFormatter children(Stream<Doc> children) {
+        this.children = children.collect(Collectors.toList());
+        return this;
+    }
+
+    BracketFormatter extractChildren(TreeCursor cursor, Function<TreeCursor, Stream<Doc>> extractor) {
+        return children(extractor.apply(cursor));
+    }
+
+    BracketFormatter detectHardLines(TreeCursor hardLineSubject) {
+        forceLineBreaks = hasHardLine(hardLineSubject);
+        return this;
+    }
+
+    // Check if the given tree has any hard lines. Nested arrays and objects are always considered hard lines.
+    private boolean hasHardLine(TreeCursor cursor) {
+        List<TreeCursor> children = cursor.findChildrenByType(
+                TreeType.COMMENT, TreeType.TEXT_BLOCK, TreeType.NODE_ARRAY, TreeType.NODE_OBJECT,
+                TreeType.QUOTED_TEXT);
+        for (TreeCursor child : children) {
+            if (child.getTree().getType() != TreeType.QUOTED_TEXT) {
+                return true;
+            } else if (child.getTree().getStartLine() != child.getTree().getEndLine()) {
+                // Detect strings with line breaks.
+                return true;
+            }
+        }
+        return false;
+    }
+
+    BracketFormatter forceLineBreaks(boolean forceLineBreaks) {
+        this.forceLineBreaks = forceLineBreaks;
+        return this;
+    }
+
+    BracketFormatter forceLineBreaksIfNotEmpty() {
+        if (!children.isEmpty()) {
+            forceLineBreaks = true;
+        }
+        return this;
+    }
+
+    Doc write() {
+        SmithyBuilder.requiredState("open", open);
+        SmithyBuilder.requiredState("close", close);
+        SmithyBuilder.requiredState("children", children);
+        if (forceLineBreaks) {
+            return FormatVisitor.renderBlock(open, close, Doc.intersperse(Doc.line(), children));
+        } else {
+            return Doc.intersperse(Formatter.LINE_OR_COMMA, children).bracket(4, Doc.lineOrEmpty(), open, close);
+        }
+    }
+
+    private static final class Extractor implements Function<TreeCursor, Stream<Doc>> {
+        private final Function<TreeCursor, Stream<TreeCursor>> mapper;
+        private final Function<TreeCursor, Doc> visitor;
+
+        private Extractor(
+                Function<TreeCursor, Doc> visitor,
+                Function<TreeCursor, Stream<TreeCursor>> mapper
+        ) {
+            this.visitor = visitor;
+            this.mapper = mapper;
+        }
+
+        @Override
+        public Stream<Doc> apply(TreeCursor cursor) {
+            SmithyBuilder.requiredState("childExtractor", mapper);
+            SmithyBuilder.requiredState("visitor", visitor);
+            return cursor.children()
+                    .flatMap(c -> {
+                        TreeType type = c.getTree().getType();
+                        return type == TreeType.WS || type == TreeType.COMMENT ? Stream.of(c) : mapper.apply(c);
+                    })
+                    .flatMap(c -> {
+                        // If the child extracts WS, then filter it down to just comments.
+                        return c.getTree().getType() == TreeType.WS
+                               ? c.getChildrenByType(TreeType.COMMENT).stream()
+                               : Stream.of(c);
+                    })
+                    .map(visitor)
+                    .filter(doc -> doc != Doc.empty());
+        }
+    }
+}
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/FormatVisitor.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/FormatVisitor.java
new file mode 100644
index 000000000..273523b35
--- /dev/null
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/FormatVisitor.java
@@ -0,0 +1,702 @@
+/*
+ * Copyright 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.syntax;
+
+import com.opencastsoftware.prettier4j.Doc;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import software.amazon.smithy.model.shapes.ShapeId;
+
+final class FormatVisitor {
+
+    // width is needed since intermediate renders are used to detect when newlines are used in a statement.
+    private final int width;
+
+    // Used to handle extracting comments out of whitespace of prior statements.
+    private Doc pendingComments = Doc.empty();
+
+    FormatVisitor(int width) {
+        this.width = width;
+    }
+
+    // Renders members and anything bracketed that are known to need expansion on multiple lines.
+    static Doc renderBlock(Doc open, Doc close, Doc contents) {
+        return open
+                .append(Doc.line().append(contents).indent(4))
+                .append(Doc.line())
+                .append(close);
+    }
+
+    Doc visit(TreeCursor cursor) {
+        if (cursor == null) {
+            return Doc.empty();
+        }
+
+        TokenTree tree = cursor.getTree();
+
+        switch (tree.getType()) {
+            case IDL: {
+                return visit(cursor.getFirstChild(TreeType.WS))
+                        .append(visit(cursor.getFirstChild(TreeType.CONTROL_SECTION)))
+                        .append(visit(cursor.getFirstChild(TreeType.METADATA_SECTION)))
+                        .append(visit(cursor.getFirstChild(TreeType.SHAPE_SECTION)))
+                        .append(flushBrBuffer());
+            }
+
+            case CONTROL_SECTION: {
+                return section(cursor, TreeType.CONTROL_STATEMENT);
+            }
+
+            case METADATA_SECTION: {
+                return section(cursor, TreeType.METADATA_STATEMENT);
+            }
+
+            case SHAPE_SECTION: {
+                return Doc.intersperse(Doc.line(), cursor.children().map(this::visit));
+            }
+
+            case SHAPE_STATEMENTS: {
+                Doc result = Doc.empty();
+                Iterator<TreeCursor> childIterator = cursor.getChildren().iterator();
+                int i = 0;
+                while (childIterator.hasNext()) {
+                    if (i++ > 0) {
+                        result = result.append(Doc.line());
+                    }
+                    result = result.append(visit(childIterator.next())) // SHAPE
+                            .append(visit(childIterator.next()))        // BR
+                            .append(Doc.line());
+                }
+                return result;
+            }
+
+            case CONTROL_STATEMENT: {
+                return flushBrBuffer()
+                        .append(Doc.text("$"))
+                        .append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY)))
+                        .append(Doc.text(": "))
+                        .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
+                        .append(visit(cursor.getFirstChild(TreeType.BR)));
+            }
+
+            case METADATA_STATEMENT: {
+                return flushBrBuffer()
+                        .append(Doc.text("metadata "))
+                        .append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY)))
+                        .append(Doc.text(" = "))
+                        .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
+                        .append(visit(cursor.getFirstChild(TreeType.BR)));
+            }
+
+            case NAMESPACE_STATEMENT: {
+                return Doc.line()
+                        .append(flushBrBuffer())
+                        .append(Doc.text("namespace "))
+                        .append(visit(cursor.getFirstChild(TreeType.NAMESPACE)))
+                        .append(visit(cursor.getFirstChild(TreeType.BR)));
+            }
+
+            case USE_SECTION: {
+                return section(cursor, TreeType.USE_STATEMENT);
+            }
+
+            case USE_STATEMENT: {
+                return flushBrBuffer()
+                        .append(Doc.text("use "))
+                        .append(visit(cursor.getFirstChild(TreeType.ABSOLUTE_ROOT_SHAPE_ID)))
+                        .append(visit(cursor.getFirstChild(TreeType.BR)));
+            }
+
+            case SHAPE_OR_APPLY_STATEMENT:
+            case SHAPE:
+            case OPERATION_PROPERTY:
+            case APPLY_STATEMENT:
+            case NODE_VALUE:
+            case NODE_KEYWORD:
+            case NODE_STRING_VALUE:
+            case SIMPLE_TYPE_NAME:
+            case ENUM_TYPE_NAME:
+            case AGGREGATE_TYPE_NAME:
+            case ENTITY_TYPE_NAME: {
+                return visit(cursor.getFirstChild());
+            }
+
+            case SHAPE_STATEMENT: {
+                return flushBrBuffer()
+                        .append(visit(cursor.getFirstChild(TreeType.WS)))
+                        .append(visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS)))
+                        .append(visit(cursor.getFirstChild(TreeType.SHAPE)));
+            }
+
+            case SIMPLE_SHAPE: {
+                return formatShape(cursor, visit(cursor.getFirstChild(TreeType.SIMPLE_TYPE_NAME)), null);
+            }
+
+            case ENUM_SHAPE: {
+                return skippedComments(cursor, false)
+                        .append(formatShape(
+                                cursor,
+                                visit(cursor.getFirstChild(TreeType.ENUM_TYPE_NAME)),
+                                visit(cursor.getFirstChild(TreeType.ENUM_SHAPE_MEMBERS))));
+            }
+
+            case ENUM_SHAPE_MEMBERS: {
+                return renderMembers(cursor, TreeType.ENUM_SHAPE_MEMBER);
+            }
+
+            case ENUM_SHAPE_MEMBER: {
+                return visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
+                        .append(visit(cursor.getFirstChild(TreeType.IDENTIFIER)))
+                        .append(visit(cursor.getFirstChild(TreeType.VALUE_ASSIGNMENT)));
+            }
+
+            case AGGREGATE_SHAPE: {
+                return skippedComments(cursor, false)
+                        .append(formatShape(
+                                cursor,
+                                visit(cursor.getFirstChild(TreeType.AGGREGATE_TYPE_NAME)),
+                                visit(cursor.getFirstChild(TreeType.SHAPE_MEMBERS))));
+            }
+
+            case FOR_RESOURCE: {
+                return Doc.text("for ").append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)));
+            }
+
+            case SHAPE_MEMBERS: {
+                return renderMembers(cursor, TreeType.SHAPE_MEMBER);
+            }
+
+            case SHAPE_MEMBER: {
+                return visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
+                        .append(visit(cursor.getFirstChild(TreeType.ELIDED_SHAPE_MEMBER)))
+                        .append(visit(cursor.getFirstChild(TreeType.EXPLICIT_SHAPE_MEMBER)))
+                        .append(visit(cursor.getFirstChild(TreeType.VALUE_ASSIGNMENT)));
+            }
+
+            case EXPLICIT_SHAPE_MEMBER: {
+                return visit(cursor.getFirstChild(TreeType.IDENTIFIER))
+                        .append(Doc.text(": "))
+                        .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)));
+            }
+
+            case ELIDED_SHAPE_MEMBER: {
+                return Doc.text("$").append(visit(cursor.getFirstChild(TreeType.IDENTIFIER)));
+            }
+
+            case ENTITY_SHAPE: {
+                Doc skippedComments = skippedComments(cursor, false);
+                Doc entityType = visit(cursor.getFirstChild(TreeType.ENTITY_TYPE_NAME));
+                TreeCursor nodeCursor = cursor.getFirstChild(TreeType.NODE_OBJECT);
+                Function<TreeCursor, Doc> visitor = new EntityShapeExtractorVisitor();
+
+                // Place the values of resources, operations, and errors on multiple lines.
+                Doc body = new BracketFormatter()
+                        .extractChildren(nodeCursor, BracketFormatter.extractByType(TreeType.NODE_OBJECT_KVP, visitor))
+                        .detectHardLines(nodeCursor) // If the list is empty, then keep it as "[]".
+                        .write();
+
+                return skippedComments.append(formatShape(cursor, entityType, Doc.lineOrSpace().append(body)));
+            }
+
+            case OPERATION_SHAPE: {
+                return skippedComments(cursor, false)
+                        .append(formatShape(cursor, Doc.text("operation"),
+                                            visit(cursor.getFirstChild(TreeType.OPERATION_BODY))));
+            }
+
+            case OPERATION_BODY: {
+                return renderMembers(cursor, TreeType.OPERATION_PROPERTY);
+            }
+
+            case OPERATION_INPUT: {
+                TreeCursor simpleTarget = cursor.getFirstChild(TreeType.SHAPE_ID);
+                return skippedComments(cursor, false)
+                        .append(Doc.text("input"))
+                        .append(simpleTarget == null
+                                ? visit(cursor.getFirstChild(TreeType.INLINE_AGGREGATE_SHAPE))
+                                : Doc.text(": ")).append(visit(simpleTarget));
+            }
+
+            case OPERATION_OUTPUT: {
+                TreeCursor simpleTarget = cursor.getFirstChild(TreeType.SHAPE_ID);
+                return skippedComments(cursor, false)
+                        .append(Doc.text("output"))
+                        .append(simpleTarget == null
+                                ? visit(cursor.getFirstChild(TreeType.INLINE_AGGREGATE_SHAPE))
+                                : Doc.text(": ")).append(visit(simpleTarget));
+            }
+
+            case INLINE_AGGREGATE_SHAPE: {
+                boolean hasComment = hasComment(cursor);
+                boolean hasTraits = Optional.ofNullable(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
+                        .filter(c -> !c.getChildrenByType(TreeType.TRAIT).isEmpty())
+                        .isPresent();
+                Doc memberDoc = visit(cursor.getFirstChild(TreeType.SHAPE_MEMBERS));
+                if (hasComment || hasTraits) {
+                    return Doc.text(" :=")
+                            .append(Doc.line())
+                            .append(skippedComments(cursor, false))
+                            .append(visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS)))
+                            .append(formatShape(cursor, Doc.empty(), memberDoc))
+                            .indent(4);
+                }
+
+                return formatShape(cursor, Doc.text(" :="), memberDoc);
+            }
+
+            case OPERATION_ERRORS: {
+                return skippedComments(cursor, false)
+                        .append(Doc.text("errors: "))
+                        .append(new BracketFormatter()
+                                        .open(Formatter.LBRACKET)
+                                        .close(Formatter.RBRACKET)
+                                        .extractChildren(cursor, BracketFormatter.extractByType(TreeType.SHAPE_ID,
+                                                                                                this::visit))
+                                        .detectHardLines(cursor)
+                                        .forceLineBreaks(true) // always put each error on separate lines.
+                                        .write());
+            }
+
+            case MIXINS: {
+                return Doc.text("with ")
+                        .append(new BracketFormatter()
+                                        .open(Formatter.LBRACKET)
+                                        .close(Formatter.RBRACKET)
+                                        .extractChildren(cursor, BracketFormatter.extractor(this::visit, child -> {
+                                            return child.getTree().getType() == TreeType.SHAPE_ID
+                                                   ? Stream.of(child)
+                                                   : Stream.empty();
+                                        }))
+                                        .detectHardLines(cursor)
+                                        .write());
+            }
+
+            case VALUE_ASSIGNMENT: {
+                return Doc.text(" = ")
+                        .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
+                        .append(visit(cursor.getFirstChild(TreeType.BR)));
+            }
+
+            case TRAIT_STATEMENTS: {
+                return Doc.intersperse(
+                                Doc.line(),
+                                cursor.children()
+                                        // Skip WS nodes that have no comments.
+                                        .filter(c -> c.getTree().getType() == TreeType.TRAIT || hasComment(c))
+                                        .map(this::visit))
+                        .append(tree.isEmpty() ? Doc.empty() : Doc.line());
+            }
+
+            case TRAIT: {
+                return Doc.text("@")
+                        .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
+                        .append(visit(cursor.getFirstChild(TreeType.TRAIT_BODY)));
+            }
+
+            case TRAIT_BODY: {
+                TreeCursor structuredBody = cursor.getFirstChild(TreeType.TRAIT_STRUCTURE);
+                if (structuredBody != null) {
+                    return new BracketFormatter()
+                            .open(Formatter.LPAREN)
+                            .close(Formatter.RPAREN)
+                            .extractChildren(cursor, BracketFormatter.extractor(this::visit, child -> {
+                                if (child.getTree().getType() == TreeType.TRAIT_STRUCTURE) {
+                                    // Split WS and NODE_OBJECT_KVP so that they appear on different lines.
+                                    return child.getChildrenByType(TreeType.NODE_OBJECT_KVP, TreeType.WS).stream();
+                                }
+                                return Stream.empty();
+                            }))
+                            .detectHardLines(cursor)
+                            .write();
+                } else {
+                    // Check the inner trait node for hard line breaks rather than the wrapper.
+                    TreeCursor traitNode = cursor
+                            .getFirstChild(TreeType.TRAIT_NODE)
+                            .getFirstChild(TreeType.NODE_VALUE)
+                            .getFirstChild(); // The actual node value.
+                    return new BracketFormatter()
+                            .open(Formatter.LPAREN)
+                            .close(Formatter.RPAREN)
+                            .extractChildren(cursor, BracketFormatter.extractor(this::visit, child -> {
+                                if (child.getTree().getType() == TreeType.TRAIT_NODE) {
+                                    // Split WS and NODE_VALUE so that they appear on different lines.
+                                    return child.getChildrenByType(TreeType.NODE_VALUE, TreeType.WS).stream();
+                                } else {
+                                    return Stream.empty();
+                                }
+                            }))
+                            .detectHardLines(traitNode)
+                            .write();
+                }
+            }
+
+            case TRAIT_NODE: {
+                return visit(cursor.getFirstChild()).append(visit(cursor.getFirstChild(TreeType.WS)));
+            }
+
+            case TRAIT_STRUCTURE: {
+                throw new UnsupportedOperationException("Use TRAIT_BODY");
+            }
+
+            case APPLY_STATEMENT_SINGULAR: {
+                // If there is an awkward comment before the TRAIT value, hoist it above the statement.
+                return flushBrBuffer()
+                        .append(skippedComments(cursor, false))
+                        .append(Doc.text("apply "))
+                        .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
+                        .append(Formatter.SPACE)
+                        .append(visit(cursor.getFirstChild(TreeType.TRAIT)));
+            }
+
+            case APPLY_STATEMENT_BLOCK: {
+                // TODO: This renders the "apply" block as a string so that we can trim the contents before adding
+                //   the trailing newline + closing bracket. Otherwise, we'll get a blank, indented line, before
+                //   the closing brace.
+                return flushBrBuffer()
+                        .append(Doc.text(skippedComments(cursor, false)
+                                                 .append(Doc.text("apply "))
+                                                 .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
+                                                 .append(Doc.text(" {"))
+                                                 .append(Doc.line().append(visit(cursor.getFirstChild(
+                                                         TreeType.TRAIT_STATEMENTS)))
+                                                                 .indent(4))
+                                                 .render(width)
+                                                 .trim())
+                                        .append(Doc.line())
+                                        .append(Formatter.RBRACE));
+            }
+
+            case NODE_ARRAY: {
+                return new BracketFormatter()
+                        .open(Formatter.LBRACKET)
+                        .close(Formatter.RBRACKET)
+                        .extractChildren(cursor, BracketFormatter.extractByType(TreeType.NODE_VALUE, this::visit))
+                        .detectHardLines(cursor)
+                        .write();
+            }
+
+            case NODE_OBJECT: {
+                return new BracketFormatter()
+                        .extractChildren(cursor, BracketFormatter.extractByType(TreeType.NODE_OBJECT_KVP,
+                                                                                this::visit))
+                        .detectHardLines(cursor)
+                        .write();
+            }
+
+            case NODE_OBJECT_KVP: {
+                return skippedComments(cursor, false)
+                        .append(formatNodeObjectKvp(cursor, this::visit, this::visit));
+            }
+
+            case NODE_OBJECT_KEY: {
+                // Unquote object keys that can be unquoted.
+                CharSequence unquoted = Optional.ofNullable(cursor.getFirstChild(TreeType.QUOTED_TEXT))
+                        .flatMap(quoted -> quoted.getTree().tokens().findFirst())
+                        .map(token -> token.getLexeme().subSequence(1, token.getSpan() - 1))
+                        .orElse("");
+                return ShapeId.isValidIdentifier(unquoted)
+                       ? Doc.text(unquoted.toString())
+                       : Doc.text(tree.concatTokens());
+            }
+
+            case TEXT_BLOCK: {
+                // Dispersing the lines of the text block preserves any indentation applied from formatting parent
+                // nodes.
+                List<Doc> lines = Arrays.stream(tree.concatTokens().split(System.lineSeparator()))
+                        .map(String::trim)
+                        .map(Doc::text)
+                        .collect(Collectors.toList());
+                return Doc.intersperse(Doc.line(), lines);
+            }
+
+            case TOKEN:
+            case QUOTED_TEXT:
+            case NUMBER:
+            case SHAPE_ID:
+            case ROOT_SHAPE_ID:
+            case ABSOLUTE_ROOT_SHAPE_ID:
+            case SHAPE_ID_MEMBER:
+            case NAMESPACE:
+            case IDENTIFIER: {
+                return Doc.text(tree.concatTokens());
+            }
+
+            case COMMENT: {
+                // Ensure comments have a single space before their content.
+                String contents = tree.concatTokens().trim();
+                if (contents.startsWith("/// ") || contents.startsWith("// ")) {
+                    return Doc.text(contents);
+                } else if (contents.startsWith("///")) {
+                    return Doc.text("/// " + contents.substring(3));
+                } else {
+                    return Doc.text("// " + contents.substring(2));
+                }
+            }
+
+            case WS: {
+                // Ignore all whitespace except for comments and doc comments.
+                return Doc.intersperse(
+                        Doc.line(),
+                        cursor.getChildrenByType(TreeType.COMMENT).stream().map(this::visit)
+                );
+            }
+
+            case BR: {
+                pendingComments = Doc.empty();
+                Doc result = Doc.empty();
+                List<TreeCursor> comments = getComments(cursor);
+                for (TreeCursor comment : comments) {
+                    if (comment.getTree().getStartLine() == tree.getStartLine()) {
+                        result = result.append(Formatter.SPACE.append(visit(comment)));
+                    } else {
+                        pendingComments = pendingComments.append(visit(comment)).append(Doc.line());
+                    }
+                }
+                return result;
+            }
+
+            default: {
+                return Doc.empty();
+            }
+        }
+    }
+
+    private Doc formatShape(TreeCursor cursor, Doc type, Doc members) {
+        List<Doc> docs = new EmptyIgnoringList();
+        docs.add(type);
+        docs.add(visit(cursor.getFirstChild(TreeType.IDENTIFIER)));
+        docs.add(visit(cursor.getFirstChild(TreeType.FOR_RESOURCE)));
+        docs.add(visit(cursor.getFirstChild(TreeType.MIXINS)));
+        Doc result = Doc.intersperse(Formatter.SPACE, docs);
+        return members != null ? result.append(Doc.group(members)) : result;
+    }
+
+    private static final class EmptyIgnoringList extends ArrayList<Doc> {
+        @Override
+        public boolean add(Doc doc) {
+            return doc != Doc.empty() && super.add(doc);
+        }
+    }
+
+    private Doc flushBrBuffer() {
+        Doc result = pendingComments;
+        pendingComments = Doc.empty();
+        return result;
+    }
+
+    // Check if a cursor contains direct child comments or a direct child WS that contains comments.
+    private static boolean hasComment(TreeCursor cursor) {
+        return !getComments(cursor).isEmpty();
+    }
+
+    // Get direct child comments from a cursor, or from direct WS children that have comments.
+    private static List<TreeCursor> getComments(TreeCursor cursor) {
+        List<TreeCursor> result = new ArrayList<>();
+        for (TreeCursor wsOrComment : cursor.getChildrenByType(TreeType.COMMENT, TreeType.WS)) {
+            if (wsOrComment.getTree().getType() == TreeType.WS) {
+                result.addAll(wsOrComment.getChildrenByType(TreeType.COMMENT));
+            } else {
+                result.add(wsOrComment);
+            }
+        }
+        return result;
+    }
+
+    // Concatenate all comments in a tree into a single line delimited Doc.
+    private Doc skippedComments(TreeCursor cursor, boolean leadingLine) {
+        List<TreeCursor> comments = getComments(cursor);
+        if (comments.isEmpty()) {
+            return Doc.empty();
+        }
+        List<Doc> docs = new ArrayList<>(comments.size());
+        comments.forEach(c -> docs.add(visit(c).append(Doc.line())));
+        return (leadingLine ? Doc.line() : Doc.empty()).append(Doc.fold(docs, Doc::append));
+    }
+
+    // Renders "members" in braces, grouping related comments and members together.
+    private Doc renderMembers(TreeCursor container, TreeType memberType) {
+        boolean noComments = container.findChildrenByType(TreeType.COMMENT, TreeType.TRAIT).isEmpty();
+        // Separate members by a single line if none have traits or docs, and two lines if any do.
+        Doc separator = noComments ? Doc.line() : Doc.line().append(Doc.line());
+        List<TreeCursor> members = container.getChildrenByType(memberType, TreeType.WS);
+        // Remove WS we don't care about.
+        members.removeIf(c -> c.getTree().getType() == TreeType.WS && !hasComment(c));
+        // Empty structures render as "{}".
+        if (noComments && members.isEmpty()) {
+            return Doc.group(Formatter.LINE_OR_SPACE.append(Doc.text("{}")));
+        }
+
+        // Group consecutive comments and members together, and add a new line after each member.
+        List<Doc> memberDocs = new ArrayList<>();
+        // Start the current result with a buffered comment, if any, or an empty Doc.
+        Doc current = flushBrBuffer();
+        boolean newLineNeededAfterComment = false;
+
+        for (TreeCursor member : members) {
+            if (member.getTree().getType() == TreeType.WS) {
+                newLineNeededAfterComment = true;
+                current = current.append(visit(member));
+            } else {
+                if (newLineNeededAfterComment) {
+                    current = current.append(Doc.line());
+                    newLineNeededAfterComment = false;
+                }
+                current = current.append(visit(member));
+                memberDocs.add(current);
+                current = flushBrBuffer();
+            }
+        }
+
+        if (current != Doc.empty()) {
+            memberDocs.add(current);
+        }
+
+        Doc open = Formatter.LINE_OR_SPACE.append(Formatter.LBRACE);
+        return renderBlock(open, Formatter.RBRACE, Doc.intersperse(separator, memberDocs));
+    }
+
+    // Renders control, metadata, and use sections so that each statement has a leading and trailing newline
+    // IFF the statement spans multiple lines (i.e., long value that wraps, comments, etc).
+    private Doc section(TreeCursor cursor, TreeType childType) {
+        List<TreeCursor> children = cursor.getChildrenByType(childType);
+
+        // Empty sections emit no code.
+        if (children.isEmpty()) {
+            return Doc.empty();
+        }
+
+        // Tracks when a line was just written.
+        // Initialized to false since there's no need to ever add a leading line in a section of statements.
+        boolean justWroteTrailingLine = true;
+
+        // Sections need a new line to separate them from the previous content.
+        // Note: even though this emits a leading newline in every generated model, a top-level String#trim() is
+        // used to clean this up.
+        Doc result = Doc.line();
+
+        for (int i = 0; i < children.size(); i++) {
+            boolean isLast = i == children.size() - 1;
+            TreeCursor child = children.get(i);
+
+            // Render the child to a String to detect if a newline was rendered. This is fine to do here since all
+            // statements that use this method are rooted at column 0 with no indentation. This rendered text is
+            // also used as part of the generated Doc since there's no need to re-analyze each statement.
+            String rendered = visit(child).render(width);
+
+            if (rendered.contains(System.lineSeparator())) {
+                if (!justWroteTrailingLine) {
+                    result = result.append(Doc.line());
+                }
+                result = result.append(Doc.text(rendered));
+                if (!isLast) {
+                    result = result.append(Doc.line());
+                    justWroteTrailingLine = true;
+                }
+            } else {
+                result = result.append(Doc.text(rendered));
+                justWroteTrailingLine = false;
+            }
+
+            result = result.append(Doc.line());
+        }
+
+        return result;
+    }
+
+    private static Doc formatNodeObjectKvp(
+            TreeCursor cursor,
+            Function<TreeCursor, Doc> keyVisitor,
+            Function<TreeCursor, Doc> valueVisitor
+    ) {
+        // Since text blocks span multiple lines, when they are the NODE_VALUE for NODE_OBJECT_KVP,
+        // they have to be indented. Since we only format valid models, NODE_OBJECT_KVP is guaranteed to
+        // have a NODE_VALUE child.
+        TreeCursor nodeValue = cursor.getFirstChild(TreeType.NODE_VALUE);
+        boolean isTextBlock = Optional.ofNullable(nodeValue.getFirstChild(TreeType.NODE_STRING_VALUE))
+                .map(nodeString -> nodeString.getFirstChild(TreeType.TEXT_BLOCK))
+                .isPresent();
+        Doc nodeValueDoc = valueVisitor.apply(nodeValue);
+
+        if (isTextBlock) {
+            nodeValueDoc = nodeValueDoc.indent(4);
+        }
+
+        // Hoist awkward comments in the KVP *before* the KVP rather than between the values and colon.
+        // If there is an awkward comment before the TRAIT value, hoist it above the statement.
+        return keyVisitor.apply(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY))
+                .append(Doc.text(": "))
+                .append(nodeValueDoc);
+    }
+
+    // Ensure that special key-value pairs of service and resource shapes are always on multiple lines if not empty.
+    private final class EntityShapeExtractorVisitor implements Function<TreeCursor, Doc> {
+
+        // Format known NODE_OBJECT_KVP list values to always place items on multiple lines.
+        private final Function<TreeCursor, Doc> hardLineList = value -> {
+            value = value.getFirstChild(TreeType.NODE_ARRAY);
+            return new BracketFormatter()
+                    .open(Formatter.LBRACKET)
+                    .close(Formatter.RBRACKET)
+                    .extractChildren(value, BracketFormatter
+                            .extractByType(TreeType.NODE_VALUE, FormatVisitor.this::visit))
+                    .forceLineBreaksIfNotEmpty()
+                    .write();
+        };
+
+        // Format known NODE_OBJECT_KVP object values to always place them on multiple lines.
+        private final Function<TreeCursor, Doc> hardLineObject = value -> {
+            value = value.getFirstChild(TreeType.NODE_OBJECT);
+            return new BracketFormatter()
+                    .extractChildren(value, BracketFormatter
+                            .extractByType(TreeType.NODE_OBJECT_KVP, FormatVisitor.this::visit))
+                    .forceLineBreaksIfNotEmpty()
+                    .write();
+        };
+
+        @Override
+        public Doc apply(TreeCursor c) {
+            if (c.getTree().getType() != TreeType.NODE_OBJECT_KVP) {
+                return visit(c);
+            }
+
+            TreeCursor key = c.getFirstChild(TreeType.NODE_OBJECT_KEY);
+            String keyValue = key.getTree().concatTokens();
+
+            // Remove quotes if found.
+            if (key.getTree().getType() == TreeType.QUOTED_TEXT) {
+                keyValue = keyValue.substring(1, keyValue.length() - 1);
+            }
+
+            switch (keyValue) {
+                case "resources":
+                case "operations":
+                case "collectionOperations":
+                case "errors":
+                    return formatNodeObjectKvp(c, FormatVisitor.this::visit, hardLineList);
+                case "rename":
+                    return formatNodeObjectKvp(c, FormatVisitor.this::visit, hardLineObject);
+                default:
+                    return visit(c);
+            }
+        }
+    }
+}
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/Formatter.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/Formatter.java
index 67b93af31..9c2d19bd6 100644
--- a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/Formatter.java
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/Formatter.java
@@ -16,17 +16,8 @@
 package software.amazon.smithy.syntax;
 
 import com.opencastsoftware.prettier4j.Doc;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Iterator;
 import java.util.List;
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
 import software.amazon.smithy.model.loader.ModelSyntaxException;
-import software.amazon.smithy.model.shapes.ShapeId;
-import software.amazon.smithy.utils.SmithyBuilder;
 import software.amazon.smithy.utils.StringUtils;
 
 /**
@@ -37,6 +28,16 @@ import software.amazon.smithy.utils.StringUtils;
  */
 public final class Formatter {
 
+    static final Doc LBRACE = Doc.text("{");
+    static final Doc RBRACE = Doc.text("}");
+    static final Doc LBRACKET = Doc.text("[");
+    static final Doc RBRACKET = Doc.text("]");
+    static final Doc LPAREN = Doc.text("(");
+    static final Doc RPAREN = Doc.text(")");
+    static final Doc LINE_OR_COMMA = Doc.lineOr(Doc.text(", "));
+    static final Doc SPACE = Doc.text(" ");
+    static final Doc LINE_OR_SPACE = Doc.lineOrSpace();
+
     private Formatter() {}
 
     /**
@@ -71,699 +72,11 @@ public final class Formatter {
         root = new RemoveUnusedUseStatements().apply(root);
 
         // Strip trailing spaces from each line.
-        String result = new TreeVisitor(maxWidth).visit(root.zipper()).render(maxWidth).trim();
+        String result = new FormatVisitor(maxWidth).visit(root.zipper()).render(maxWidth).trim();
         StringBuilder builder = new StringBuilder();
         for (String line : result.split(System.lineSeparator())) {
             builder.append(StringUtils.stripEnd(line, " \t")).append(System.lineSeparator());
         }
         return builder.toString();
     }
-
-    private static final class TreeVisitor {
-
-        private static final Doc LINE_OR_COMMA = Doc.lineOr(Doc.text(", "));
-        private static final Doc SPACE = Doc.text(" ");
-        private static final Doc LINE_OR_SPACE = Doc.lineOrSpace();
-
-        // width is needed since intermediate renders are used to detect when newlines are used in a statement.
-        private final int width;
-
-        // Used to handle extracting comments out of whitespace of prior statements.
-        private Doc pendingComments = Doc.empty();
-
-        private TreeVisitor(int width) {
-            this.width = width;
-        }
-
-        private Doc visit(TreeCursor cursor) {
-            if (cursor == null) {
-                return Doc.empty();
-            }
-
-            TokenTree tree = cursor.getTree();
-
-            switch (tree.getType()) {
-                case IDL: {
-                    return visit(cursor.getFirstChild(TreeType.WS))
-                            .append(visit(cursor.getFirstChild(TreeType.CONTROL_SECTION)))
-                            .append(visit(cursor.getFirstChild(TreeType.METADATA_SECTION)))
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE_SECTION)))
-                            .append(flushBrBuffer());
-                }
-
-                case CONTROL_SECTION: {
-                    return section(cursor, TreeType.CONTROL_STATEMENT);
-                }
-
-                case METADATA_SECTION: {
-                    return section(cursor, TreeType.METADATA_STATEMENT);
-                }
-
-                case SHAPE_SECTION: {
-                    return Doc.intersperse(Doc.line(), cursor.children().map(this::visit));
-                }
-
-                case SHAPE_STATEMENTS: {
-                    Doc result = Doc.empty();
-                    Iterator<TreeCursor> childIterator = cursor.getChildren().iterator();
-                    int i = 0;
-                    while (childIterator.hasNext()) {
-                        if (i++ > 0) {
-                            result = result.append(Doc.line());
-                        }
-                        result = result.append(visit(childIterator.next())) // SHAPE
-                                .append(visit(childIterator.next()))        // BR
-                                .append(Doc.line());
-                    }
-                    return result;
-                }
-
-                case CONTROL_STATEMENT: {
-                    return flushBrBuffer()
-                            .append(Doc.text("$"))
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY)))
-                            .append(Doc.text(": "))
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
-                            .append(visit(cursor.getFirstChild(TreeType.BR)));
-                }
-
-                case METADATA_STATEMENT: {
-                    return flushBrBuffer()
-                            .append(Doc.text("metadata "))
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY)))
-                            .append(Doc.text(" = "))
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
-                            .append(visit(cursor.getFirstChild(TreeType.BR)));
-                }
-
-                case NAMESPACE_STATEMENT: {
-                    return Doc.line()
-                            .append(flushBrBuffer())
-                            .append(Doc.text("namespace "))
-                            .append(visit(cursor.getFirstChild(TreeType.NAMESPACE)))
-                            .append(visit(cursor.getFirstChild(TreeType.BR)));
-                }
-
-                case USE_SECTION: {
-                    return section(cursor, TreeType.USE_STATEMENT);
-                }
-
-                case USE_STATEMENT: {
-                    return flushBrBuffer()
-                            .append(Doc.text("use "))
-                            .append(visit(cursor.getFirstChild(TreeType.ABSOLUTE_ROOT_SHAPE_ID)))
-                            .append(visit(cursor.getFirstChild(TreeType.BR)));
-                }
-
-                case SHAPE_OR_APPLY_STATEMENT:
-                case SHAPE:
-                case OPERATION_PROPERTY:
-                case APPLY_STATEMENT:
-                case NODE_VALUE:
-                case NODE_KEYWORD:
-                case NODE_STRING_VALUE:
-                case SIMPLE_TYPE_NAME:
-                case ENUM_TYPE_NAME:
-                case AGGREGATE_TYPE_NAME:
-                case ENTITY_TYPE_NAME: {
-                    return visit(cursor.getFirstChild());
-                }
-
-                case SHAPE_STATEMENT: {
-                    return flushBrBuffer()
-                            .append(visit(cursor.getFirstChild(TreeType.WS)))
-                            .append(visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS)))
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE)));
-                }
-
-                case SIMPLE_SHAPE: {
-                    return formatShape(cursor, visit(cursor.getFirstChild(TreeType.SIMPLE_TYPE_NAME)), null);
-                }
-
-                case ENUM_SHAPE: {
-                    return skippedComments(cursor, false)
-                            .append(formatShape(
-                                    cursor,
-                                    visit(cursor.getFirstChild(TreeType.ENUM_TYPE_NAME)),
-                                    visit(cursor.getFirstChild(TreeType.ENUM_SHAPE_MEMBERS))));
-                }
-
-                case ENUM_SHAPE_MEMBERS: {
-                    return renderMembers(cursor, TreeType.ENUM_SHAPE_MEMBER);
-                }
-
-                case ENUM_SHAPE_MEMBER: {
-                    return visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
-                            .append(visit(cursor.getFirstChild(TreeType.IDENTIFIER)))
-                            .append(visit(cursor.getFirstChild(TreeType.VALUE_ASSIGNMENT)));
-                }
-
-                case AGGREGATE_SHAPE: {
-                    return skippedComments(cursor, false)
-                            .append(formatShape(
-                                    cursor,
-                                    visit(cursor.getFirstChild(TreeType.AGGREGATE_TYPE_NAME)),
-                                    visit(cursor.getFirstChild(TreeType.SHAPE_MEMBERS))));
-                }
-
-                case FOR_RESOURCE: {
-                    return Doc.text("for ").append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)));
-                }
-
-                case SHAPE_MEMBERS: {
-                    return renderMembers(cursor, TreeType.SHAPE_MEMBER);
-                }
-
-                case SHAPE_MEMBER: {
-                    return visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
-                            .append(visit(cursor.getFirstChild(TreeType.ELIDED_SHAPE_MEMBER)))
-                            .append(visit(cursor.getFirstChild(TreeType.EXPLICIT_SHAPE_MEMBER)))
-                            .append(visit(cursor.getFirstChild(TreeType.VALUE_ASSIGNMENT)));
-                }
-
-                case EXPLICIT_SHAPE_MEMBER: {
-                    return visit(cursor.getFirstChild(TreeType.IDENTIFIER))
-                            .append(Doc.text(": "))
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)));
-                }
-
-                case ELIDED_SHAPE_MEMBER: {
-                    return Doc.text("$").append(visit(cursor.getFirstChild(TreeType.IDENTIFIER)));
-                }
-
-                case ENTITY_SHAPE: {
-                    return skippedComments(cursor, false)
-                            .append(formatShape(
-                                    cursor,
-                                    visit(cursor.getFirstChild(TreeType.ENTITY_TYPE_NAME)),
-                                    Doc.lineOrSpace().append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT)))));
-                }
-
-                case OPERATION_SHAPE: {
-                    return skippedComments(cursor, false)
-                            .append(formatShape(cursor, Doc.text("operation"),
-                                                visit(cursor.getFirstChild(TreeType.OPERATION_BODY))));
-                }
-
-                case OPERATION_BODY: {
-                    return renderMembers(cursor, TreeType.OPERATION_PROPERTY);
-                }
-
-                case OPERATION_INPUT: {
-                    TreeCursor simpleTarget = cursor.getFirstChild(TreeType.SHAPE_ID);
-                    return skippedComments(cursor, false)
-                            .append(Doc.text("input"))
-                            .append(simpleTarget == null
-                                    ? visit(cursor.getFirstChild(TreeType.INLINE_AGGREGATE_SHAPE))
-                                    : Doc.text(": ")).append(visit(simpleTarget));
-                }
-
-                case OPERATION_OUTPUT: {
-                    TreeCursor simpleTarget = cursor.getFirstChild(TreeType.SHAPE_ID);
-                    return skippedComments(cursor, false)
-                            .append(Doc.text("output"))
-                            .append(simpleTarget == null
-                                    ? visit(cursor.getFirstChild(TreeType.INLINE_AGGREGATE_SHAPE))
-                                    : Doc.text(": ")).append(visit(simpleTarget));
-                }
-
-                case INLINE_AGGREGATE_SHAPE: {
-                    boolean hasComment = hasComment(cursor);
-                    boolean hasTraits = Optional.ofNullable(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))
-                                                  .filter(c -> !c.getChildrenByType(TreeType.TRAIT).isEmpty())
-                                                  .isPresent();
-                    Doc memberDoc = visit(cursor.getFirstChild(TreeType.SHAPE_MEMBERS));
-                    if (hasComment || hasTraits) {
-                        return Doc.text(" :=")
-                                .append(Doc.line())
-                                .append(skippedComments(cursor, false))
-                                .append(visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS)))
-                                .append(formatShape(cursor, Doc.empty(), memberDoc))
-                                .indent(4);
-                    }
-
-                    return formatShape(cursor, Doc.text(" :="), memberDoc);
-                }
-
-                case OPERATION_ERRORS: {
-                    return skippedComments(cursor, false)
-                            .append(Doc.text("errors: "))
-                            .append(new BracketBuilder()
-                                            .open(Doc.text("["))
-                                            .close(Doc.text("]"))
-                                            .extractChildrenByType(cursor, TreeType.SHAPE_ID)
-                                            .forceLineBreaks(true) // always put each error on separate lines.
-                                            .build());
-                }
-
-                case MIXINS: {
-                    return Doc.text("with ")
-                            .append(new BracketBuilder()
-                                            .open(Doc.text("["))
-                                            .close(Doc.text("]"))
-                                            .extractChildren(cursor, cursor, child -> {
-                                                return child.getTree().getType() == TreeType.SHAPE_ID
-                                                       ? Stream.of(child)
-                                                       : Stream.empty();
-                                            }).build());
-                }
-
-                case VALUE_ASSIGNMENT: {
-                    return Doc.text(" = ")
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_VALUE)))
-                            .append(visit(cursor.getFirstChild(TreeType.BR)));
-                }
-
-                case TRAIT_STATEMENTS: {
-                    return Doc.intersperse(
-                            Doc.line(),
-                            cursor.children()
-                                    // Skip WS nodes that have no comments.
-                                    .filter(c -> c.getTree().getType() == TreeType.TRAIT || hasComment(c))
-                                    .map(this::visit))
-                            .append(tree.isEmpty() ? Doc.empty() : Doc.line());
-                }
-
-                case TRAIT: {
-                    return Doc.text("@")
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
-                            .append(visit(cursor.getFirstChild(TreeType.TRAIT_BODY)));
-                }
-
-                case TRAIT_BODY: {
-                    TreeCursor structuredBody = cursor.getFirstChild(TreeType.TRAIT_STRUCTURE);
-                    if (structuredBody != null) {
-                        return new BracketBuilder()
-                                .open(Doc.text("("))
-                                .close(Doc.text(")"))
-                                .extractChildren(cursor, cursor, child -> {
-                                    if (child.getTree().getType() == TreeType.TRAIT_STRUCTURE) {
-                                        // Split WS and NODE_OBJECT_KVP so that they appear on different lines.
-                                        return child.getChildrenByType(TreeType.NODE_OBJECT_KVP, TreeType.WS).stream();
-                                    }
-                                    return Stream.empty();
-                                })
-                                .build();
-                    } else {
-                        // Check the inner trait node for hard line breaks rather than the wrapper.
-                        TreeCursor traitNode = cursor
-                                .getFirstChild(TreeType.TRAIT_NODE)
-                                .getFirstChild(TreeType.NODE_VALUE)
-                                .getFirstChild(); // The actual node value.
-                        return new BracketBuilder()
-                                .open(Doc.text("("))
-                                .close(Doc.text(")"))
-                                .extractChildren(cursor, traitNode, child -> {
-                                    if (child.getTree().getType() == TreeType.TRAIT_NODE) {
-                                        // Split WS and NODE_VALUE so that they appear on different lines.
-                                        return child.getChildrenByType(TreeType.NODE_VALUE, TreeType.WS).stream();
-                                    } else {
-                                        return Stream.empty();
-                                    }
-                                })
-                                .build();
-                    }
-                }
-
-                case TRAIT_NODE: {
-                    return visit(cursor.getFirstChild()).append(visit(cursor.getFirstChild(TreeType.WS)));
-                }
-
-                case TRAIT_STRUCTURE: {
-                    throw new UnsupportedOperationException("Use TRAIT_BODY");
-                }
-
-                case APPLY_STATEMENT_SINGULAR: {
-                    // If there is an awkward comment before the TRAIT value, hoist it above the statement.
-                    return flushBrBuffer()
-                            .append(skippedComments(cursor, false))
-                            .append(Doc.text("apply "))
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
-                            .append(SPACE)
-                            .append(visit(cursor.getFirstChild(TreeType.TRAIT)));
-                }
-
-                case APPLY_STATEMENT_BLOCK: {
-                    // TODO: This renders the "apply" block as a string so that we can trim the contents before adding
-                    //   the trailing newline + closing bracket. Otherwise, we'll get a blank, indented line, before
-                    //   the closing brace.
-                    return flushBrBuffer()
-                            .append(Doc.text(skippedComments(cursor, false)
-                            .append(Doc.text("apply "))
-                            .append(visit(cursor.getFirstChild(TreeType.SHAPE_ID)))
-                            .append(Doc.text(" {"))
-                            .append(Doc.line().append(visit(cursor.getFirstChild(TreeType.TRAIT_STATEMENTS))).indent(4))
-                            .render(width)
-                            .trim())
-                            .append(Doc.line())
-                            .append(Doc.text("}")));
-                }
-
-                case NODE_ARRAY: {
-                    return new BracketBuilder()
-                            .open(Doc.text("["))
-                            .close(Doc.text("]"))
-                            .extractChildrenByType(cursor, TreeType.NODE_VALUE)
-                            .build();
-                }
-
-                case NODE_OBJECT: {
-                    return new BracketBuilder()
-                            .open(Doc.text("{"))
-                            .close(Doc.text("}"))
-                            .extractChildrenByType(cursor, TreeType.NODE_OBJECT_KVP)
-                            .build();
-                }
-
-                case NODE_OBJECT_KVP: {
-                    // Since text blocks span multiple lines, when they are the NODE_VALUE for NODE_OBJECT_KVP,
-                    // they have to be indented. Since we only format valid models, NODE_OBJECT_KVP is guaranteed to
-                    // have a NODE_VALUE child.
-                    TreeCursor nodeValue = cursor.getFirstChild(TreeType.NODE_VALUE);
-                    boolean isTextBlock = Optional.ofNullable(nodeValue.getFirstChild(TreeType.NODE_STRING_VALUE))
-                            .map(nodeString -> nodeString.getFirstChild(TreeType.TEXT_BLOCK))
-                            .isPresent();
-                    Doc nodeValueDoc = visit(nodeValue);
-                    if (isTextBlock) {
-                        nodeValueDoc = nodeValueDoc.indent(4);
-                    }
-
-
-                    // Hoist awkward comments in the KVP *before* the KVP rather than between the values and colon.
-                    // If there is an awkward comment before the TRAIT value, hoist it above the statement.
-                    return skippedComments(cursor, false)
-                            .append(visit(cursor.getFirstChild(TreeType.NODE_OBJECT_KEY)))
-                            .append(Doc.text(": "))
-                            .append(nodeValueDoc);
-                }
-
-                case NODE_OBJECT_KEY: {
-                    // Unquote object keys that can be unquoted.
-                    CharSequence unquoted = Optional.ofNullable(cursor.getFirstChild(TreeType.QUOTED_TEXT))
-                            .flatMap(quoted -> quoted.getTree().tokens().findFirst())
-                            .map(token -> token.getLexeme().subSequence(1, token.getSpan() - 1))
-                            .orElse("");
-                    return ShapeId.isValidIdentifier(unquoted)
-                           ? Doc.text(unquoted.toString())
-                           : Doc.text(tree.concatTokens());
-                }
-
-                case TEXT_BLOCK: {
-                    // Dispersing the lines of the text block preserves any indentation applied from formatting parent
-                    // nodes.
-                    List<Doc> lines = Arrays.stream(tree.concatTokens().split(System.lineSeparator()))
-                            .map(String::trim)
-                            .map(Doc::text)
-                            .collect(Collectors.toList());
-                    return Doc.intersperse(Doc.line(), lines);
-                }
-
-                case TOKEN:
-                case QUOTED_TEXT:
-                case NUMBER:
-                case SHAPE_ID:
-                case ROOT_SHAPE_ID:
-                case ABSOLUTE_ROOT_SHAPE_ID:
-                case SHAPE_ID_MEMBER:
-                case NAMESPACE:
-                case IDENTIFIER: {
-                    return Doc.text(tree.concatTokens());
-                }
-
-                case COMMENT: {
-                    // Ensure comments have a single space before their content.
-                    String contents = tree.concatTokens().trim();
-                    if (contents.startsWith("/// ") || contents.startsWith("// ")) {
-                        return Doc.text(contents);
-                    } else if (contents.startsWith("///")) {
-                        return Doc.text("/// " + contents.substring(3));
-                    } else {
-                        return Doc.text("// " + contents.substring(2));
-                    }
-                }
-
-                case WS: {
-                    // Ignore all whitespace except for comments and doc comments.
-                    return Doc.intersperse(
-                        Doc.line(),
-                        cursor.getChildrenByType(TreeType.COMMENT).stream().map(this::visit)
-                    );
-                }
-
-                case BR: {
-                    pendingComments = Doc.empty();
-                    Doc result = Doc.empty();
-                    List<TreeCursor> comments = getComments(cursor);
-                    for (TreeCursor comment : comments) {
-                        if (comment.getTree().getStartLine() == tree.getStartLine()) {
-                            result = result.append(SPACE.append(visit(comment)));
-                        } else {
-                            pendingComments = pendingComments.append(visit(comment)).append(Doc.line());
-                        }
-                    }
-                    return result;
-                }
-
-                default: {
-                    return Doc.empty();
-                }
-            }
-        }
-
-        private Doc formatShape(TreeCursor cursor, Doc type, Doc members) {
-            List<Doc> docs = new EmptyIgnoringList();
-            docs.add(type);
-            docs.add(visit(cursor.getFirstChild(TreeType.IDENTIFIER)));
-            docs.add(visit(cursor.getFirstChild(TreeType.FOR_RESOURCE)));
-            docs.add(visit(cursor.getFirstChild(TreeType.MIXINS)));
-            Doc result = Doc.intersperse(SPACE, docs);
-            return members != null ? result.append(Doc.group(members)) : result;
-        }
-
-        private static final class EmptyIgnoringList extends ArrayList<Doc> {
-            @Override
-            public boolean add(Doc doc) {
-                return doc != Doc.empty() && super.add(doc);
-            }
-        }
-
-        private Doc flushBrBuffer() {
-            Doc result = pendingComments;
-            pendingComments = Doc.empty();
-            return result;
-        }
-
-        // Check if a cursor contains direct child comments or a direct child WS that contains comments.
-        private boolean hasComment(TreeCursor cursor) {
-            return !getComments(cursor).isEmpty();
-        }
-
-        // Get direct child comments from a cursor, or from direct WS children that have comments.
-        private List<TreeCursor> getComments(TreeCursor cursor) {
-            List<TreeCursor> result = new ArrayList<>();
-            for (TreeCursor wsOrComment : cursor.getChildrenByType(TreeType.COMMENT, TreeType.WS)) {
-                if (wsOrComment.getTree().getType() == TreeType.WS) {
-                    result.addAll(wsOrComment.getChildrenByType(TreeType.COMMENT));
-                } else {
-                    result.add(wsOrComment);
-                }
-            }
-            return result;
-        }
-
-        // Concatenate all comments in a tree into a single line delimited Doc.
-        private Doc skippedComments(TreeCursor cursor, boolean leadingLine) {
-            List<TreeCursor> comments = getComments(cursor);
-            if (comments.isEmpty()) {
-                return Doc.empty();
-            }
-            List<Doc> docs = new ArrayList<>(comments.size());
-            comments.forEach(c -> docs.add(visit(c).append(Doc.line())));
-            return (leadingLine ? Doc.line() : Doc.empty()).append(Doc.fold(docs, Doc::append));
-        }
-
-        // Renders "members" in braces, grouping related comments and members together.
-        private Doc renderMembers(TreeCursor container, TreeType memberType) {
-            boolean noComments = container.findChildrenByType(TreeType.COMMENT, TreeType.TRAIT).isEmpty();
-            // Separate members by a single line if none have traits or docs, and two lines if any do.
-            Doc separator = noComments ? Doc.line() : Doc.line().append(Doc.line());
-            List<TreeCursor> members = container.getChildrenByType(memberType, TreeType.WS);
-            // Remove WS we don't care about.
-            members.removeIf(c -> c.getTree().getType() == TreeType.WS && !hasComment(c));
-            // Empty structures render as "{}".
-            if (noComments && members.isEmpty()) {
-                return Doc.group(LINE_OR_SPACE.append(Doc.text("{}")));
-            }
-
-            // Group consecutive comments and members together, and add a new line after each member.
-            List<Doc> memberDocs = new ArrayList<>();
-            // Start the current result with a buffered comment, if any, or an empty Doc.
-            Doc current = flushBrBuffer();
-            boolean newLineNeededAfterComment = false;
-
-            for (TreeCursor member : members) {
-                if (member.getTree().getType() == TreeType.WS) {
-                    newLineNeededAfterComment = true;
-                    current = current.append(visit(member));
-                } else {
-                    if (newLineNeededAfterComment) {
-                        current = current.append(Doc.line());
-                        newLineNeededAfterComment = false;
-                    }
-                    current = current.append(visit(member));
-                    memberDocs.add(current);
-                    current = flushBrBuffer();
-                }
-            }
-
-            if (current != Doc.empty()) {
-                memberDocs.add(current);
-            }
-
-            Doc open = LINE_OR_SPACE.append(Doc.text("{"));
-            return renderBlock(open, Doc.text("}"), Doc.intersperse(separator, memberDocs));
-        }
-
-        // Renders members and anything bracketed that are known to need expansion on multiple lines.
-        private static Doc renderBlock(Doc open, Doc close, Doc contents) {
-            return open
-                    .append(Doc.line().append(contents).indent(4))
-                    .append(Doc.line())
-                    .append(close);
-        }
-
-        // Renders control, metadata, and use sections so that each statement has a leading and trailing newline
-        // IFF the statement spans multiple lines (i.e., long value that wraps, comments, etc).
-        private Doc section(TreeCursor cursor, TreeType childType) {
-            List<TreeCursor> children = cursor.getChildrenByType(childType);
-
-            // Empty sections emit no code.
-            if (children.isEmpty()) {
-                return Doc.empty();
-            }
-
-            // Tracks when a line was just written.
-            // Initialized to false since there's no need to ever add a leading line in a section of statements.
-            boolean justWroteTrailingLine = true;
-
-            // Sections need a new line to separate them from the previous content.
-            // Note: even though this emits a leading newline in every generated model, a top-level String#trim() is
-            // used to clean this up.
-            Doc result = Doc.line();
-
-            for (int i = 0; i < children.size(); i++) {
-                boolean isLast = i == children.size() - 1;
-                TreeCursor child = children.get(i);
-
-                // Render the child to a String to detect if a newline was rendered. This is fine to do here since all
-                // statements that use this method are rooted at column 0 with no indentation. This rendered text is
-                // also used as part of the generated Doc since there's no need to re-analyze each statement.
-                String rendered = visit(child).render(width);
-
-                if (rendered.contains(System.lineSeparator())) {
-                    if (!justWroteTrailingLine) {
-                        result = result.append(Doc.line());
-                    }
-                    result = result.append(Doc.text(rendered));
-                    if (!isLast) {
-                        result = result.append(Doc.line());
-                        justWroteTrailingLine = true;
-                    }
-                } else {
-                    result = result.append(Doc.text(rendered));
-                    justWroteTrailingLine = false;
-                }
-
-                result = result.append(Doc.line());
-            }
-
-            return result;
-        }
-
-        private final class BracketBuilder {
-            Doc open;
-            Doc close;
-            Stream<Doc> children;
-            boolean forceLineBreaks;
-
-            BracketBuilder open(Doc open) {
-                this.open = open;
-                return this;
-            }
-
-            BracketBuilder close(Doc close) {
-                this.close = close;
-                return this;
-            }
-
-            BracketBuilder children(Stream<Doc> children) {
-                this.children = children;
-                return this;
-            }
-
-            // Brackets children of childType between open and closed brackets. If the children can fit together
-            // on a single line, they are comma separated. If not, they are split onto multiple lines with no commas.
-            BracketBuilder extractChildrenByType(TreeCursor cursor, TreeType childType) {
-                return extractChildren(cursor, cursor, child -> child.getTree().getType() == childType
-                                                                ? Stream.of(child) : Stream.empty());
-            }
-
-            BracketBuilder extractChildren(
-                    TreeCursor cursor,
-                    TreeCursor hardLineSubject,
-                    Function<TreeCursor, Stream<TreeCursor>> childExtractor
-            ) {
-                children(cursor.children()
-                                 .flatMap(c -> {
-                                     TreeType type = c.getTree().getType();
-                                     return type == TreeType.WS || type == TreeType.COMMENT
-                                            ? Stream.of(c)
-                                            : childExtractor.apply(c);
-                                 })
-                                 .flatMap(c -> {
-                                     // If the child extracts WS, then filter it down to just comments.
-                                     return c.getTree().getType() == TreeType.WS
-                                            ? c.getChildrenByType(TreeType.COMMENT).stream()
-                                            : Stream.of(c);
-                                 })
-                                 .map(TreeVisitor.this::visit)
-                                 .filter(doc -> doc != Doc.empty()));
-
-                forceLineBreaks = hasHardLine(hardLineSubject);
-                return this;
-            }
-
-            // Check if the given tree has any hard lines. Nested arrays and objects are always considered hard lines.
-            private boolean hasHardLine(TreeCursor cursor) {
-                List<TreeCursor> children = cursor.findChildrenByType(
-                        TreeType.COMMENT, TreeType.TEXT_BLOCK, TreeType.NODE_ARRAY, TreeType.NODE_OBJECT,
-                        TreeType.QUOTED_TEXT);
-                for (TreeCursor child : children) {
-                    if (child.getTree().getType() != TreeType.QUOTED_TEXT) {
-                        return true;
-                    } else if (child.getTree().getStartLine() != child.getTree().getEndLine()) {
-                        // Detect strings with line breaks.
-                        return true;
-                    }
-                }
-                return false;
-            }
-
-            BracketBuilder forceLineBreaks(boolean forceLineBreaks) {
-                this.forceLineBreaks = forceLineBreaks;
-                return this;
-            }
-
-            Doc build() {
-                SmithyBuilder.requiredState("open", open);
-                SmithyBuilder.requiredState("close", close);
-                SmithyBuilder.requiredState("children", children);
-                if (forceLineBreaks) {
-                    return renderBlock(open, close, Doc.intersperse(Doc.line(), children));
-                } else {
-                    return Doc.intersperse(LINE_OR_COMMA, children).bracket(4, Doc.lineOrEmpty(), open, close);
-                }
-            }
-        }
-    }
 }


Test patch:
diff --git a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedMetadataTest.java b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedMetadataTest.java
index 2ad1649bd..c0c63be50 100644
--- a/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedMetadataTest.java
+++ b/smithy-diff/src/test/java/software/amazon/smithy/diff/evaluators/RemovedMetadataTest.java
@@ -32,6 +32,6 @@ public class RemovedMetadataTest {
         Model modelB = Model.assembler().assemble().unwrap();
         List<ValidationEvent> events = ModelDiff.compare(modelA, modelB);
 
-        assertThat(TestHelper.findEvents(events, "RemovedMetadata").size(), equalTo(1));
+        assertThat(TestHelper.findEvents(events, "RemovedMetadata.foo").size(), equalTo(1));
     }
 }
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/aggregate-shapes.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/aggregate-shapes.smithy
index fbfaa6637..2a807e85a 100644
--- a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/aggregate-shapes.smithy
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/aggregate-shapes.smithy
@@ -86,7 +86,9 @@ structure HasMixins with [MyMixin1, MyMixin2] {
 structure HasForResource for MyResource with [MyMixin1] {}
 
 resource MyResource {
-    operations: [PutMyResource]
+    operations: [
+        PutMyResource
+    ]
 }
 
 operation PutMyResource {
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.formatted.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.formatted.smithy
new file mode 100644
index 000000000..f4aac3766
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.formatted.smithy
@@ -0,0 +1,40 @@
+$version: "2.0"
+
+namespace smithy.example
+
+/// Documentation
+@auth([httpBasicAuth])
+service Foo {
+    version: "2"
+    operations: [
+        GetTime1
+    ]
+    resources: []
+    errors: [
+        E1
+        E2
+    ]
+}
+
+operation GetTime1 {}
+
+resource Sprocket1 {
+    operations: [
+        GetTime2
+    ]
+}
+
+operation GetTime2 {
+    input := {}
+}
+
+@http(method: "X", uri: "/foo", code: 200)
+resource Sprocket2 {
+    identifiers: {username: String, id: String, otherId: String}
+}
+
+@error("client")
+structure E1 {}
+
+@error("client")
+structure E2 {}
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.smithy
new file mode 100644
index 000000000..314544851
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.smithy
@@ -0,0 +1,34 @@
+$version: "2.0"
+
+namespace smithy.example
+
+/// Documentation
+@auth([httpBasicAuth])
+service Foo {
+    version: "2"
+    operations: [GetTime1]
+    resources: []
+    errors: [E1
+             E2]
+}
+
+operation GetTime1 {}
+
+resource Sprocket1 {
+    operations: [GetTime2]
+}
+
+operation GetTime2 {
+    input := {}
+}
+
+@http(method: "X", uri: "/foo", code: 200)
+resource Sprocket2 {
+    identifiers: {username: String, id: String, otherId: String}
+}
+
+@error("client")
+structure E1 {}
+
+@error("client")
+structure E2 {}
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes.smithy
index 8018e75f5..585e574ef 100644
--- a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes.smithy
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes.smithy
@@ -5,9 +5,25 @@ namespace smithy.example
 /// Documentation
 @auth([httpBasicAuth])
 service Foo {
+    version: "1"
+    operations: [
+        GetTime1
+        GetTime2
+    ]
+    resources: [
+        Sprocket1
+        Sprocket2
+    ]
+    rename: {
+        "smithy.example#SomeOperationFoo": "SomeOperationFooRenamed"
+    }
+}
+
+service Foo2 {
     version: "2"
-    operations: [GetTime1, GetTime2]
-    resources: [Sprocket1, Sprocket2]
+    operations: []
+    resources: []
+    rename: {}
 }
 
 operation GetTime1 {}
@@ -23,4 +39,22 @@ resource Sprocket1 {
 @http(method: "X", uri: "/foo", code: 200)
 resource Sprocket2 {
     identifiers: {username: String, id: String, otherId: String}
+    collectionOperations: [
+        SomeOperation
+    ]
+}
+
+operation SomeOperation {
+    input := {
+        foo: SomeOperationFoo
+    }
+}
+
+structure SomeOperationFoo {}
+
+@http(method: "X", uri: "/foo3", code: 200)
+resource Sprocket3 {
+    identifiers: {username: String, id: String, otherId: String}
+    // It's empty, so on a single line.
+    collectionOperations: []
 }