diff --git a/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeFormatter.java b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeFormatter.java index 82156ddeb2e..e032db1cb6c 100644 --- a/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeFormatter.java +++ b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeFormatter.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2020 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. @@ -41,23 +41,23 @@ void putFormatter(Character identifier, BiFunction forma formatters.put(identifier, formatter); } - String format(Object content, String indent, CodeWriter writer, Object... args) { + String format(char expressionStart, Object content, String indent, CodeWriter writer, Object... args) { String expression = String.valueOf(content); // Simple case of no arguments and no expressions. - if (args.length == 0 && expression.indexOf('$') == -1) { + if (args.length == 0 && expression.indexOf(expressionStart) == -1) { return expression; } - return parse(new State(content, indent, writer, args)); + return parse(expressionStart, new State(content, indent, writer, args)); } - private String parse(State state) { + private String parse(char expressionStart, State state) { while (!state.eof()) { char c = state.c(); state.next(); - if (c == '$') { - parseArgumentWrapper(state); + if (c == expressionStart) { + parseArgumentWrapper(expressionStart, state); } else { state.result.append(c); } @@ -74,15 +74,15 @@ private String parse(State state) { return state.result.toString(); } - private void parseArgumentWrapper(State state) { + private void parseArgumentWrapper(char expressionStart, State state) { if (state.eof()) { throw new IllegalArgumentException("Invalid format string: " + state); } char c = state.c(); - if (c == '$') { + if (c == expressionStart) { // $$ -> $ - state.result.append('$'); + state.result.append(expressionStart); state.next(); } else if (c == '{') { parseBracedArgument(state); diff --git a/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriter.java b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriter.java index d43432f30e3..fb6475430ce 100644 --- a/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriter.java +++ b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright 2020 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. @@ -35,7 +35,7 @@ *

The following example generates some Python code: * *

{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.write("def Foo(str):")
  *       .indent()
  *       .write("print str");
@@ -50,7 +50,7 @@
  * {@code write}:
  *
  * 
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.write("Hello, $L", "there!");
  * String code = writer.toString();
  * }
@@ -101,7 +101,7 @@ * interpolates the first positional argument, the second the second, etc. * *
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.write("$L $L $L", "a", "b", "c");
  * System.out.println(writer.toString());
  * // Outputs: "a b c"
@@ -117,7 +117,7 @@
  * number refers to the 1-based index of the argument to interpolate.
  *
  * 
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.write("$1L $2L $3L, $3L $2L $1L", "a", "b", "c");
  * System.out.println(writer.toString());
  * // Outputs: "a b c c b a"
@@ -135,7 +135,7 @@
  * {@code } is the name of a formatter.
  *
  * 
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.putContext("foo", "a");
  * writer.putContext("baz.bar", "b");
  * writer.write("$foo:L $baz.bar:L");
@@ -153,6 +153,26 @@
  * // Outputs: "$L"
  * }
* + *

Custom expression characters

+ * + *

The character used to start a code block expression can be customized + * to make it easier to write code that makes heavy use of {@code $}. The + * default character used to start an expression is, {@code $}, but this can + * be changed for the current state of the CodeWriter by calling + * {@link #setExpressionStart(char)}. A custom start character can be escaped + * using two start characters in a row. For example, given a custom start + * character of {@code #}, {@code #} can be escaped using {@code ##}. + * + *

{@code
+ * CodeWriter writer = new CodeWriter();
+ * writer.setExpressionStart('#');
+ * writer.write("#L ##L $L", "hi");
+ * System.out.println(writer.toString());
+ * // Outputs: "hi #L $L"
+ * }
+ * + * The start character cannot be set to ' ' or '\n'. + * *

Opening and closing blocks

* *

{@code CodeWriter} provides a short cut for opening code blocks that that @@ -163,7 +183,7 @@ * then print a formatted statement. * *

{@code
- * CodeWriter writer = CodeWriter.createDefault()
+ * CodeWriter writer = new CodeWriter()
  *       .openBlock("if ($L) {", someValue)
  *       .write("System.out.println($S);", "Hello!")
  *       .closeBlock("}");
@@ -180,11 +200,13 @@
  * 

Pushing and popping state

* *

The CodeWriter can maintain a stack of transformation states, including - * the text used to indent, a prefix to add before each line, the number of - * times to indent, a map of context values, and whether or not whitespace is - * trimmed from the end of newlines. State can be pushed onto the stack using - * {@link #pushState} which copies the current state. Mutations can then be - * made to the top-most state of the CodeWriter and do not affect previous + * the text used to indent, a prefix to add before each line, newline character, + * the number of times to indent, a map of context values, whether or not + * whitespace is trimmed from the end of newlines, whether or not the automatic + * insertion of newlines is disabled, and the character used to start code + * expressions (defaults to {@code $}). State can be pushed onto the stack + * using {@link #pushState} which copies the current state. Mutations can then + * be made to the top-most state of the CodeWriter and do not affect previous * states. The previous transformation state of the CodeWriter can later be * restored using {@link #popState}. * @@ -192,7 +214,7 @@ * This is useful for doing things like create Javadoc strings: * *

{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer
  *       .pushState()
  *       .write("/**")
@@ -223,7 +245,6 @@
  * 
    *
  • The number of successive blank lines to trim.
  • *
  • Code formatters registered through {@link #putFormatter}
  • - *
  • The character used for newlines
  • *
  • Whether or not a trailing newline is inserted or removed from * the result of converting the {@code CodeWriter} to a string.
  • *
@@ -245,7 +266,7 @@ *

In the the following example: * *

{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * String result = writer.trimTrailingSpaces().write("hello  ").toString();
  * }
* @@ -301,7 +322,7 @@ * {@code CodeBuilder}. * *
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.onSection("example", text -> writer.write("Intercepted: " + text));
  * writer.pushState("example");
  * writer.write("Original contents");
@@ -325,7 +346,7 @@
  * make calls to the {@code CodeWriter} to modify the section.
  *
  * 
{@code
- * CodeWriter writer = CodeWriter.createDefault();
+ * CodeWriter writer = new CodeWriter();
  * writer.onSection("example", text -> writer.write("Intercepted: " + text));
  * writer.write("Leading text...${L@example}...Trailing text...", "foo");
  * System.out.println(writer.toString());
@@ -343,8 +364,15 @@ public class CodeWriter {
     private State currentState;
     private boolean trailingNewline = true;
     private int trimBlankLines = -1;
-    private String newline = "\n";
-    private String newlineRegexQuoted = Pattern.quote("\n");
+
+    /**
+     * Tracks when indentation is needed following a newline.
+     *
+     * 

This is initially set to true to account for the case when a + * code writer is initialized with indentation but hasn't written + * anything yet. + */ + private boolean needsIndentation = true; /** * Creates a new CodeWriter that uses "\n" for a newline, four spaces @@ -420,6 +448,30 @@ public final CodeWriter putFormatter(char identifier, BiFunctionBy default, {@code $} is used to start expressions (for example + * {@code $L}. However, some programming languages frequently give + * syntactic meaning to {@code $}, making this an inconvenient syntactic + * character for the CodeWriter. In these cases, the character used to + * start a CodeWriter expression can be changed. Just like {@code $}, the + * custom start character can be escaped using two subsequent start + * characters (e.g., {@code $$}). + * + * @param expressionStart Character to use to start expressions. + * @return Returns the CodeWriter. + */ + public CodeWriter setExpressionStart(char expressionStart) { + if (expressionStart == ' ' || expressionStart == '\n') { + throw new IllegalArgumentException("expressionStart must not be set to " + expressionStart); + } + + currentState.expressionStart = expressionStart; + return this; + } + /** * Gets the contents of the generated code. * @@ -441,25 +493,35 @@ public String toString() { for (String line : lines) { if (!StringUtils.isBlank(line)) { - builder.append(line).append(newline); + builder.append(line).append(currentState.newline); blankCount = 0; } else if (blankCount++ < trimBlankLines) { - builder.append(line).append(newline); + builder.append(line).append(currentState.newline); } } result = builder.toString(); } - // Trailing new lines are always present by default. + if (result.isEmpty()) { + return trailingNewline ? String.valueOf(currentState.newline) : ""; + } + + // This accounts for cases where the only write on the CodeWriter was + // an inline write, but the write ended with spaces. + if (currentState.trimTrailingSpaces) { + result = StringUtils.stripEnd(result, " "); + } + if (trailingNewline) { + // Add a trailing newline if needed. + return result.charAt(result.length() - 1) != currentState.newline ? result + currentState.newline : result; + } else if (result.charAt(result.length() - 1) == currentState.newline) { + // Strip the trailing newline if present. + return result.substring(0, result.length() - 1); + } else { return result; } - - // Strip or add newlines if needed. - return result.endsWith(newline) - ? result.replaceAll(newlineRegexQuoted + "$", "") - : result; } /** @@ -567,8 +629,8 @@ private String getTrimmedPoppedStateContents(State state) { // final call to writeOptional. if (builder != null && builder.length() > 0 - && builder.lastIndexOf(newline) == builder.length() - newline.length()) { - builder.delete(builder.length() - newline.length(), builder.length()); + && builder.charAt(builder.length() - 1) == currentState.newline) { + builder.delete(builder.length() - 1, builder.length()); result = builder.toString(); } @@ -609,14 +671,67 @@ public CodeWriter onSection(String sectionName, Consumer interceptor) { } /** - * Sets the character that represents newlines ("\n" is the default). + * Disables the automatic appending of newlines in the current state. + * + *

Methods like {@link #write}, {@link #openBlock}, and {@link #closeBlock} + * will not automatically append newlines when a state has this flag set. + * + * @return Returns the CodeWriter. + */ + public CodeWriter disableNewlines() { + currentState.disableNewline = true; + return this; + } + + /** + * Enables the automatic appending of newlines in the current state. + * + * @return Returns the CodeWriter. + */ + public CodeWriter enableNewlines() { + currentState.disableNewline = false; + return this; + } + + /** + * Sets the character used to represent newlines in the current state + * ("\n" is the default). + * + *

When the provided string is empty (""), then newlines are disabled + * in the current state. This is exactly equivalent to calling + * {@link #disableNewlines()}, and does not actually change the newline + * character of the current state. + * + *

When the provided string is not empty, then the string must contain + * exactly one character. Setting the newline character to a non-empty + * string also implicitly enables newlines in the current state. * * @param newline Newline character to use. * @return Returns the CodeWriter. */ public final CodeWriter setNewline(String newline) { - this.newline = newline; - newlineRegexQuoted = Pattern.quote(newline); + if (newline.isEmpty()) { + return disableNewlines(); + } else if (newline.length() > 1) { + throw new IllegalArgumentException("newline must be set to an empty string or a single character"); + } else { + return setNewline(newline.charAt(0)); + } + } + + /** + * Sets the character used to represent newlines in the current state + * ("\n" is the default). + * + *

This call also enables newlines in the current state by calling + * {@link #enableNewlines()}. + * + * @param newline Newline character to use. + * @return Returns the CodeWriter. + */ + public final CodeWriter setNewline(char newline) { + currentState.newline = newline; + enableNewlines(); return this; } @@ -763,7 +878,7 @@ public final CodeWriter dedent(int levels) { * *

      * {@code
-     * String result = CodeWriter.createDefault()
+     * String result = new CodeWriter()
      *         .openBlock("public final class $L {", "Foo")
      *             .openBlock("public void main(String[] args) {")
      *                 .write("System.out.println(args[0]);")
@@ -787,7 +902,7 @@ public final CodeWriter openBlock(String textBeforeNewline, Object... args) {
      * syntax by writing a newline, dedenting, then writing {@code textAfterNewline}.
      *
      * 
{@code
-     * CodeWriter writer = CodeWriter.createDefault();
+     * CodeWriter writer = new CodeWriter();
      * writer.openBlock("public final class $L {", "}", "Foo", () -> {
      *     writer.openBlock("public void main(String[] args) {", "}", () -> {
      *         writer.write("System.out.println(args[0]);");
@@ -925,27 +1040,30 @@ public final CodeWriter closeBlock(String textAfterNewline, Object... args) {
     /**
      * Writes text to the CodeWriter and appends a newline.
      *
-     * 

The provided text is automatically formatted using - * variadic arguments. + *

The provided text is automatically formatted using variadic + * arguments. + * + *

Indentation and the newline prefix is only prepended if the writer's + * cursor is at the beginning of a newline. * * @param content Content to write. * @param args String arguments to use for formatting. * @return Returns the CodeWriter. */ public final CodeWriter write(Object content, Object... args) { - String value = formatter.format(content, currentState.indentText, this, args); - String[] lines = value.split(newlineRegexQuoted, -1); - - // Indent lines and strip excessive newlines. - for (String line : lines) { - currentState.writeLine(line + newline); - } - + String value = formatter.format(currentState.expressionStart, content, currentState.indentText, this, args); + currentState.writeLine(value); return this; } /** - * Writes text to the CodeWriter without appending a newline or prefixing indentation. + * Writes text to the CodeWriter without appending a newline. + * + *

The provided text is automatically formatted using variadic + * arguments. + * + *

Indentation and the newline prefix is only prepended if the writer's + * cursor is at the beginning of a newline. * *

If newlines are present in the given string, each of those lines will receive proper indentation. * @@ -954,27 +1072,8 @@ public final CodeWriter write(Object content, Object... args) { * @return Returns the CodeWriter. */ public final CodeWriter writeInline(Object content, Object... args) { - String value = formatter.format(content, currentState.indentText, this, args); - String[] lines = value.split(newlineRegexQuoted, -1); - - // The first line is written directly, with no added indentation or newline - currentState.write(lines[0]); - - // If there aren't any additional lines, return. - if (lines.length == 1) { - return this; - } - - // If there are additional lines, they need to be handled properly. So insert a newline. - currentState.write(newline); - - // Write all the intermediate lines as normal. - for (int i = 1; i <= lines.length - 2; i++) { - currentState.writeLine(lines[i] + newline); - } - - // Write the final line with proper indentation, but without an appended newline. - currentState.writeLine(lines[lines.length - 1]); + String value = formatter.format(currentState.expressionStart, content, currentState.indentText, this, args); + currentState.write(value); return this; } @@ -1078,34 +1177,40 @@ String expandSection(String sectionName, String defaultContent, Consumer } private final class State { - private String sectionName; private String indentText = " "; private String leadingIndentString = ""; private String newlinePrefix = ""; private int indentation; private boolean trimTrailingSpaces; + private boolean disableNewline; + private char newline = '\n'; + private char expressionStart = '$'; + + private transient String sectionName; /** * Inline states are created when formatting text. They aren't written * directly to the CodeWriter, but rather captured as part of the * process of expanding a template argument. */ - private boolean isInline; + private transient boolean isInline; /** This StringBuilder, if null, will only be created lazily when needed. */ private StringBuilder builder; /** The context map implements a simple copy on write pattern. */ private Map context = MapUtils.of(); - private boolean copiedContext = false; + private transient boolean copiedContext = false; /** The interceptors map implements a simple copy on write pattern. */ private Map>> interceptors = MapUtils.of(); - private boolean copiedInterceptors = false; + private transient boolean copiedInterceptors = false; State() {} - State(State copy) { + private State(State copy) { + this.newline = copy.newline; + this.expressionStart = copy.expressionStart; this.builder = copy.builder; this.context = copy.context; this.indentText = copy.indentText; @@ -1114,6 +1219,7 @@ private final class State { this.newlinePrefix = copy.newlinePrefix; this.trimTrailingSpaces = copy.trimTrailingSpaces; this.interceptors = copy.interceptors; + this.disableNewline = copy.disableNewline; } @Override @@ -1153,44 +1259,56 @@ void write(String contents) { if (builder == null) { builder = new StringBuilder(); } - builder.append(contents); - trimSpaces(false); + + // Write each character, accounting for newlines along the way. + for (int i = 0; i < contents.length(); i++) { + append(contents.charAt(i)); + } } - void writeLine(String line) { - if (builder == null) { - builder = new StringBuilder(); + void append(char c) { + if (needsIndentation) { + builder.append(leadingIndentString); + builder.append(newlinePrefix); + needsIndentation = false; } - builder.append(leadingIndentString); - builder.append(newlinePrefix); - builder.append(line); + if (c == newline) { + // The next appended character will get indentation and a + // leading prefix string. + needsIndentation = true; + // Trim spaces before each newline. This only mutates the builder + // if space trimming is enabled. + trimSpaces(); + } - // Trim all trailing spaces before the trailing (customizable) newline. - trimSpaces(true); + builder.append(c); } - private void trimSpaces(boolean skipNewline) { - if (!trimTrailingSpaces) { - return; + void writeLine(String line) { + write(line); + + if (!disableNewline) { + append(newline); } + } - int skipLength = 0; - if (skipNewline) { - skipLength = newline.length(); + private void trimSpaces() { + if (!trimTrailingSpaces) { + return; } int toRemove = 0; - for (int i = builder.length() - 1 - skipLength; i > 0; i--) { + for (int i = builder.length() - 1; i > 0; i--) { if (builder.charAt(i) == ' ') { toRemove++; } else { break; } } - // Remove the slice of the string that is made up of whitespace before the newline. + if (toRemove > 0) { - builder.delete(builder.length() - skipLength - toRemove, builder.length() - skipLength); + builder.delete(builder.length() - toRemove, builder.length()); } } diff --git a/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeFormatterTest.java b/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeFormatterTest.java index 9abab7d6456..3ae222adb4d 100644 --- a/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeFormatterTest.java +++ b/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeFormatterTest.java @@ -34,7 +34,7 @@ private static String valueOf(Object value, String indent) { @Test public void formatsDollarLiterals() { CodeFormatter formatter = new CodeFormatter(); - String result = formatter.format("hello $$", "", createWriter()); + String result = formatter.format('$', "hello $$", "", createWriter()); assertThat(result, equalTo("hello $")); } @@ -43,7 +43,7 @@ public void formatsDollarLiterals() { public void formatsRelativeLiterals() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello $L", "", createWriter(), "there"); + String result = formatter.format('$', "hello $L", "", createWriter(), "there"); assertThat(result, equalTo("hello there")); } @@ -52,7 +52,7 @@ public void formatsRelativeLiterals() { public void formatsRelativeLiteralsInBraces() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello ${L}", "", createWriter(), "there"); + String result = formatter.format('$', "hello ${L}", "", createWriter(), "there"); assertThat(result, equalTo("hello there")); } @@ -61,7 +61,7 @@ public void formatsRelativeLiteralsInBraces() { public void requiresTextAfterOpeningBrace() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); - formatter.format("hello ${", "", createWriter(), "there"); + formatter.format('$', "hello ${", "", createWriter(), "there"); }); } @@ -70,7 +70,7 @@ public void requiresBraceIsClosed() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello ${L .", "", createWriter(), "there"); + formatter.format('$', "hello ${L .", "", createWriter(), "there"); }); } @@ -78,7 +78,7 @@ public void requiresBraceIsClosed() { public void formatsMultipleRelativeLiterals() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello $L, $L", "", createWriter(), "there", "guy"); + String result = formatter.format('$', "hello $L, $L", "", createWriter(), "there", "guy"); assertThat(result, equalTo("hello there, guy")); } @@ -87,7 +87,7 @@ public void formatsMultipleRelativeLiterals() { public void formatsMultipleRelativeLiteralsInBraces() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello ${L}, ${L}", "", createWriter(), "there", "guy"); + String result = formatter.format('$', "hello ${L}, ${L}", "", createWriter(), "there", "guy"); assertThat(result, equalTo("hello there, guy")); } @@ -97,7 +97,7 @@ public void ensuresAllRelativeArgumentsWereUsed() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $L", "", createWriter(), "a", "b", "c"); + formatter.format('$', "hello $L", "", createWriter(), "a", "b", "c"); }); } @@ -106,7 +106,7 @@ public void performsRelativeBoundsChecking() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $L", "", createWriter()); + formatter.format('$', "hello $L", "", createWriter()); }); } @@ -115,7 +115,16 @@ public void validatesThatDollarIsNotAtEof() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $", "", createWriter()); + formatter.format('$', "hello $", "", createWriter()); + }); + } + + @Test + public void validatesThatCustomStartIsNotAtEof() { + Assertions.assertThrows(IllegalArgumentException.class, () -> { + CodeFormatter formatter = new CodeFormatter(); + formatter.putFormatter('L', CodeFormatterTest::valueOf); + formatter.format('#', "hello #", "", createWriter()); }); } @@ -123,7 +132,16 @@ public void validatesThatDollarIsNotAtEof() { public void formatsPositionalLiterals() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello $1L", "", createWriter(), "there"); + String result = formatter.format('$', "hello $1L", "", createWriter(), "there"); + + assertThat(result, equalTo("hello there")); + } + + @Test + public void formatsPositionalLiteralsWithCustomStart() { + CodeFormatter formatter = new CodeFormatter(); + formatter.putFormatter('L', CodeFormatterTest::valueOf); + String result = formatter.format('#', "hello #1L", "", createWriter(), "there"); assertThat(result, equalTo("hello there")); } @@ -132,7 +150,7 @@ public void formatsPositionalLiterals() { public void formatsMultiplePositionalLiterals() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello $1L, $2L. $2L? You $1L?", "", createWriter(), "there", "guy"); + String result = formatter.format('$', "hello $1L, $2L. $2L? You $1L?", "", createWriter(), "there", "guy"); assertThat(result, equalTo("hello there, guy. guy? You there?")); } @@ -141,7 +159,7 @@ public void formatsMultiplePositionalLiterals() { public void formatsMultiplePositionalLiteralsInBraces() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("hello ${1L}, ${2L}. ${2L}? You ${1L}?", "", createWriter(), "there", "guy"); + String result = formatter.format('$', "hello ${1L}, ${2L}. ${2L}? You ${1L}?", "", createWriter(), "there", "guy"); assertThat(result, equalTo("hello there, guy. guy? You there?")); } @@ -150,7 +168,7 @@ public void formatsMultiplePositionalLiteralsInBraces() { public void formatsMultipleDigitPositionalLiterals() { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - String result = formatter.format("$1L $2L $3L $4L $5L $6L $7L $8L $9L $10L $11L", "", createWriter(), + String result = formatter.format('$', "$1L $2L $3L $4L $5L $6L $7L $8L $9L $10L $11L", "", createWriter(), "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"); assertThat(result, equalTo("1 2 3 4 5 6 7 8 9 10 11")); @@ -161,7 +179,7 @@ public void performsPositionalBoundsChecking() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $1L", "", createWriter()); + formatter.format('$', "hello $1L", "", createWriter()); }); } @@ -170,7 +188,7 @@ public void performsPositionalBoundsCheckingNotZero() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $0L", "", createWriter(), "a"); + formatter.format('$', "hello $0L", "", createWriter(), "a"); }); } @@ -179,7 +197,7 @@ public void validatesThatPositionalIsNotAtEof() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $2", "", createWriter()); + formatter.format('$', "hello $2", "", createWriter()); }); } @@ -188,7 +206,7 @@ public void validatesThatAllPositionalsAreUsed() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $2L $3L", "", createWriter(), "a", "b", "c", "d"); + formatter.format('$', "hello $2L $3L", "", createWriter(), "a", "b", "c", "d"); }); } @@ -197,7 +215,7 @@ public void cannotMixPositionalAndRelative() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $1L, $L", "", createWriter(), "there"); + formatter.format('$', "hello $1L, $L", "", createWriter(), "there"); }); } @@ -206,7 +224,7 @@ public void cannotMixRelativeAndPositional() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $L, $1L", "", createWriter(), "there"); + formatter.format('$', "hello $L, $1L", "", createWriter(), "there"); }); } @@ -217,7 +235,7 @@ public void formatsNamedValues() { CodeWriter writer = createWriter(); writer.putContext("a", "a"); writer.putContext("abc_def", "b"); - String result = formatter.format("$a:L $abc_def:L", "", writer); + String result = formatter.format('$', "$a:L $abc_def:L", "", writer); assertThat(result, equalTo("a b")); } @@ -229,7 +247,7 @@ public void formatsNamedValuesInBraces() { CodeWriter writer = createWriter(); writer.putContext("a", "a"); writer.putContext("abc_def", "b"); - String result = formatter.format("${a:L} ${abc_def:L}", "", writer); + String result = formatter.format('$', "${a:L} ${abc_def:L}", "", writer); assertThat(result, equalTo("a b")); } @@ -239,7 +257,7 @@ public void ensuresNamedValuesHasColon() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $abc foo", "", createWriter()); + formatter.format('$', "hello $abc foo", "", createWriter()); }); } @@ -248,7 +266,7 @@ public void ensuresNamedValuesHasFormatterAfterColon() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("hello $abc:", "", createWriter()); + formatter.format('$', "hello $abc:", "", createWriter()); }); } @@ -260,8 +278,8 @@ public void allowsSeveralSpecialCharactersInNamedArguments() { CodeWriter writer = createWriter(); writer.putContext("foo.baz#Bar$bam", "hello"); writer.putContext("foo_baz", "hello"); - assertThat(formatter.format("$foo.baz#Bar$bam:L", "", writer), equalTo("hello")); - assertThat(formatter.format("$foo_baz:L", "", writer), equalTo("hello")); + assertThat(formatter.format('$', "$foo.baz#Bar$bam:L", "", writer), equalTo("hello")); + assertThat(formatter.format('$', "$foo_baz:L", "", writer), equalTo("hello")); } @Test @@ -269,7 +287,7 @@ public void ensuresNamedValuesMatchRegex() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); formatter.putFormatter('L', CodeFormatterTest::valueOf); - formatter.format("$nope!:L", "", createWriter()); + formatter.format('$', "$nope!:L", "", createWriter()); }); } @@ -301,7 +319,7 @@ public void formattersMustNotBeDollar() { public void ensuresFormatterIsValid() { Assertions.assertThrows(IllegalArgumentException.class, () -> { CodeFormatter formatter = new CodeFormatter(); - formatter.format("$L", "", createWriter(), "hi"); + formatter.format('$', "$L", "", createWriter(), "hi"); }); } @@ -311,7 +329,7 @@ public void expandsInlineSectionsWithDefaults() { formatter.putFormatter('L', CodeFormatterTest::valueOf); CodeWriter writer = createWriter(); - assertThat(formatter.format("${L@hello}", "", writer, "default"), equalTo("default")); + assertThat(formatter.format('$', "${L@hello}", "", writer, "default"), equalTo("default")); } @Test diff --git a/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeWriterTest.java b/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeWriterTest.java index d93f07b6dea..e15f4ae4076 100644 --- a/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeWriterTest.java +++ b/smithy-utils/src/test/java/software/amazon/smithy/utils/CodeWriterTest.java @@ -63,6 +63,16 @@ public void trimsTrailingSpaces() { assertThat(writer.toString(), equalTo("hello there\n")); } + @Test + public void toStringCanDisableTrimmingTrailingSpaces() { + CodeWriter writer = new CodeWriter() + .insertTrailingNewline(false) + .trimTrailingSpaces(false) + .writeInline("hi "); + + assertThat(writer.toString(), equalTo("hi ")); + } + @Test public void trimsSpacesAndBlankLines() { CodeWriter writer = new CodeWriter().trimTrailingSpaces().trimBlankLines(); @@ -79,6 +89,13 @@ public void insertsTrailingNewlines() { assertThat(writer.toString(), equalTo("hello there, bud\n")); } + @Test + public void trailingNewlineIsAddedToEmptyText() { + CodeWriter writer = new CodeWriter().insertTrailingNewline(); + + assertThat(writer.toString(), equalTo("\n")); + } + @Test public void canWriteTextWithNewlinePrefixAndBlankLineTrimming() { CodeWriter writer = CodeWriter.createDefault(); @@ -509,6 +526,7 @@ public void poppedSectionsEscapeDollars() { @Test public void canWriteInline() { String result = CodeWriter.createDefault() + .insertTrailingNewline(false) .writeInline("foo") .writeInline(", bar") .toString(); @@ -519,6 +537,7 @@ public void canWriteInline() { @Test public void writeInlineHandlesSingleNewline() { String result = CodeWriter.createDefault() + .insertTrailingNewline(false) .writeInline("foo").indent() .writeInline(":\nbar") .toString(); @@ -529,6 +548,7 @@ public void writeInlineHandlesSingleNewline() { @Test public void writeInlineHandlesMultipleNewlines() { String result = CodeWriter.createDefault() + .insertTrailingNewline(false) .writeInline("foo:") .writeInline(" [").indent() .writeInline("\nbar,\nbaz,\nbam,") @@ -541,10 +561,113 @@ public void writeInlineHandlesMultipleNewlines() { @Test public void writeInlineStripsSpaces() { String result = CodeWriter.createDefault() + .insertTrailingNewline(false) .trimTrailingSpaces() .writeInline("foo ") .toString(); assertThat(result, equalTo("foo")); } + + @Test + public void writeInlineDoesNotAllowIndentationToBeEscaped() { + String result = CodeWriter.createDefault() + .setIndentText("\t") + .insertTrailingNewline(false) + .indent() + .indent() + .writeInline("{foo:") + .writeInline(" [\n") + .indent() + .writeInline("hi,\nbye") + .dedent() + .writeInline("\n]\n") + .dedent() + .writeInline("}") + .toString(); + + assertThat(result, equalTo("\t\t{foo: [\n\t\t\thi,\n\t\t\tbye\n\t\t]\n\t}")); + } + + @Test + public void newlineLengthMustBe1() { + Assertions.assertThrows(IllegalArgumentException.class, () -> { + CodeWriter.createDefault().setNewline(" "); + }); + } + + @Test + public void newlineCanBeDisabled() { + CodeWriter writer = CodeWriter + .createDefault() + .insertTrailingNewline(); + String result = writer + .disableNewlines() + .openBlock("[", "]", () -> writer.write("hi")) + .toString(); + + assertThat(result, equalTo("[hi]\n")); + } + + @Test + public void newlineCanBeDisabledWithEmptyString() { + CodeWriter writer = CodeWriter + .createDefault() + .insertTrailingNewline(); + String result = writer + .setNewline("") + .openBlock("[", "]", () -> writer.write("hi")) + .enableNewlines() + .toString(); + + assertThat(result, equalTo("[hi]\n")); + } + + @Test + public void settingNewlineEnablesNewlines() { + CodeWriter writer = CodeWriter.createDefault(); + String result = writer + .disableNewlines() + .setNewline("\n") + .openBlock("[", "]", () -> writer.write("hi")) + .toString(); + + assertThat(result, equalTo("[\n hi\n]\n")); + } + + @Test + public void canSetCustomExpressionStartChar() { + CodeWriter writer = new CodeWriter(); + writer.pushState(); + writer.setExpressionStart('#'); + writer.write("Hi, #L", "1"); + writer.write("Hi, ##L"); + writer.write("Hi, $L"); + writer.write("Hi, $$L"); + writer.popState(); + writer.write("Hi, #L"); + writer.write("Hi, ##L"); + writer.write("Hi, $L", "2"); + writer.write("Hi, $$L"); + String result = writer.toString(); + + assertThat(result, equalTo("Hi, 1\n" + + "Hi, #L\n" + + "Hi, $L\n" + + "Hi, $$L\n" + + "Hi, #L\n" + + "Hi, ##L\n" + + "Hi, 2\n" + + "Hi, $L\n")); + } + + @Test + public void expressionStartCannotBeSpace() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new CodeWriter().setExpressionStart(' ')); + } + + @Test + public void expressionStartCannotBeNewline() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new CodeWriter().setExpressionStart('\n')); + } }