From 9385ad374f21917c9a0354bc5a139d5935fa6121 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 21 Aug 2023 19:51:05 -0500 Subject: [PATCH] Use hard line breaks for some properties The operations, collectionOperations, resources, and rename properties of service and resource shapes are now always on multiple lines if provided and not empty. For example: ``` service Foo { operations: [ PutFoo ] } ``` Includes a restructuring of smithy-syntax since it was getting unwieldy. --- .../smithy/syntax/BracketFormatter.java | 148 ++++ .../amazon/smithy/syntax/FormatVisitor.java | 702 +++++++++++++++++ .../amazon/smithy/syntax/Formatter.java | 709 +----------------- .../syntax/formatter/aggregate-shapes.smithy | 4 +- .../entity-shapes-line-break.formatted.smithy | 40 + .../formatter/entity-shapes-line-break.smithy | 34 + .../syntax/formatter/entity-shapes.smithy | 38 +- 7 files changed, 974 insertions(+), 701 deletions(-) create mode 100644 smithy-syntax/src/main/java/software/amazon/smithy/syntax/BracketFormatter.java create mode 100644 smithy-syntax/src/main/java/software/amazon/smithy/syntax/FormatVisitor.java create mode 100644 smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.formatted.smithy create mode 100644 smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/entity-shapes-line-break.smithy 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 00000000000..dfb992fdd8c --- /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 children; + private boolean forceLineBreaks; + + static Function> extractor( + Function visitor, + Function> 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> extractByType( + TreeType childType, + Function 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 children) { + this.children = children.collect(Collectors.toList()); + return this; + } + + BracketFormatter extractChildren(TreeCursor cursor, Function> 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 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> { + private final Function> mapper; + private final Function visitor; + + private Extractor( + Function visitor, + Function> mapper + ) { + this.visitor = visitor; + this.mapper = mapper; + } + + @Override + public Stream 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 00000000000..273523b3588 --- /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 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 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 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 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 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 { + @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 getComments(TreeCursor cursor) { + List 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 comments = getComments(cursor); + if (comments.isEmpty()) { + return Doc.empty(); + } + List 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 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 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 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 keyVisitor, + Function 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 { + + // Format known NODE_OBJECT_KVP list values to always place items on multiple lines. + private final Function 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 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 67b93af3188..9c2d19bd662 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 @@ */ 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 static String format(TokenTree root, int maxWidth) { 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 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 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 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 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 { - @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 getComments(TreeCursor cursor) { - List 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 comments = getComments(cursor); - if (comments.isEmpty()) { - return Doc.empty(); - } - List 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 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 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 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 children; - boolean forceLineBreaks; - - BracketBuilder open(Doc open) { - this.open = open; - return this; - } - - BracketBuilder close(Doc close) { - this.close = close; - return this; - } - - BracketBuilder children(Stream 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> 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 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); - } - } - } - } } 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 fbfaa66374b..2a807e85a08 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 00000000000..f4aac37662a --- /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 00000000000..31454485112 --- /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 8018e75f584..585e574ef3b 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: [] }