From 3ca3861d6ab463a793e6c59e33ab073d3a21f29b Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 28 Jan 2022 15:19:31 -0800 Subject: [PATCH] Add getters and ability to copy settings between CodeWriters It's sometimes useful to create other CodeWriters to do some work that uses the same settings as another CodeWriter. The `copySettingsFrom` method of CodeWriter takes another CodeWriter and copies its global and state settings into itself. This change also updates formatters registered with a CodeWriter to be per/state. This means that if a formatter is added in a specific state, then what that state is popped, the formatter is also removed and the CodeWriter returns to its original state. Closes #1066 --- .../amazon/smithy/utils/CodeFormatter.java | 40 +++- .../amazon/smithy/utils/CodeWriter.java | 178 ++++++++++++++++-- .../amazon/smithy/utils/CodeWriterTest.java | 71 +++++++ 3 files changed, 266 insertions(+), 23 deletions(-) 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 217fb699cb1..478394ea5cd 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 @@ -32,13 +32,43 @@ final class CodeFormatter { 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', ']', '^', '_', '`', '{', '|', '}', '~'); private final Map> formatters = new HashMap<>(); + private final CodeFormatter parentFormatter; - void putFormatter(Character identifier, BiFunction formatter) { + CodeFormatter() { + this(null); + } + + /** + * Create a CodeFormatter that also uses formatters of a parent CodeFormatter. + * + * @param parentFormatter Optional parent CodeFormatter to query when expanding formatters. + */ + CodeFormatter(CodeFormatter parentFormatter) { + this.parentFormatter = parentFormatter; + } + + void putFormatter(Character identifier, BiFunction formatFunction) { if (!VALID_FORMATTER_CHARS.contains(identifier)) { throw new IllegalArgumentException("Invalid formatter identifier: " + identifier); } - formatters.put(identifier, formatter); + formatters.put(identifier, formatFunction); + } + + /** + * Gets a formatter function for a specific character. + * + * @param identifier Formatter identifier. + * @return Returns the found formatter, or null. + */ + BiFunction getFormatter(char identifier) { + BiFunction result = formatters.get(identifier); + + if (result == null && parentFormatter != null) { + result = parentFormatter.getFormatter(identifier); + } + + return result; } String format(char expressionStart, Object content, String indent, CodeWriter writer, Object... args) { @@ -211,12 +241,14 @@ private Object getPositionalArgument(String content, int index, Object[] args) { } private String applyFormatter(State state, char formatter, Object argument, int startingBraceColumn) { - if (!formatters.containsKey(formatter)) { + BiFunction formatFunction = getFormatter(formatter); + + if (formatFunction == null) { throw new IllegalArgumentException(String.format( "Unknown formatter `%s` found in format string: %s", formatter, state)); } - String result = formatters.get(formatter).apply(argument, state.indent); + String result = formatFunction.apply(argument, state.indent); if (!state.eof() && state.c() == '@') { if (startingBraceColumn == -1) { 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 18e83a6c0a3..24988de405d 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 @@ -85,8 +85,12 @@ * programming languages. * * - *

Custom formatters can be registered using {@link #putFormatter}. The identifier - * given to a formatter must match the following ABNF: + *

Custom formatters can be registered using {@link #putFormatter}. Custom + * formatters can be used only within the state they were added. Because states + * inherit the formatters of parent states, adding a formatter to the root state + * of the CodeWriter allows the formatter to be used in any state. + * + *

The identifier given to a formatter must match the following ABNF: * *

  * %x21-23    ; ( '!' - '#' )
@@ -204,12 +208,12 @@
  * 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}.
+ * insertion of newlines is disabled, the character used to start code
+ * expressions (defaults to {@code $}), and formatters. 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}.
  *
  * 

The CodeWriter is stateful, and a prefix can be added before each line. * This is useful for doing things like create Javadoc strings: @@ -245,7 +249,6 @@ * *

    *
  • The number of successive blank lines to trim.
  • - *
  • Code formatters registered through {@link #putFormatter}
  • *
  • Whether or not a trailing newline is inserted or removed from * the result of converting the {@code CodeWriter} to a string.
  • *
@@ -387,7 +390,6 @@ public class CodeWriter { 'L', (s, i) -> formatLiteral(s), 'S', (s, i) -> StringUtils.escapeJavaString(formatLiteral(s), i)); - private final CodeFormatter formatter = new CodeFormatter(); private final Deque states = new ArrayDeque<>(); private State currentState; private boolean trailingNewline = true; @@ -412,7 +414,6 @@ public CodeWriter() { states.push(new State()); currentState = states.getFirst(); currentState.builder = new StringBuilder(); - DEFAULT_FORMATTERS.forEach(formatter::putFormatter); } /** @@ -427,6 +428,41 @@ public static CodeWriter createDefault() { return new CodeWriter().trimTrailingSpaces(); } + /** + * Copies settings from the given CodeWriter into this CodeWriter. + * + *

The settings of the {@code other} CodeWriter will overwrite + * both global and state-based settings of this CodeWriter. Formatters of + * the {@code other} CodeWriter will be merged with the formatters of this + * CodeWriter, and in the case of conflicts, the formatters of the + * {@code other} will take precedence. + * + *

Stateful settings of the {@code other} CodeWriter are copied into + * the current state of this CodeWriter. Only the settings of + * the top-most state is copied. Other states, and the contents of the + * top-most state are not copied. + * + *

{@code
+     * CodeWriter a = new CodeWriter();
+     * a.setExpressionStart('#');
+     *
+     * CodeWriter b = new CodeWriter();
+     * b.copySettingsFrom(a);
+     *
+     * assert(b.getExpressionStart() == '#');
+     * }
+ * + * @param other CodeWriter to copy settings from. + */ + public void copySettingsFrom(CodeWriter other) { + // Copy global settings. + trailingNewline = other.trailingNewline; + trimBlankLines = other.trimBlankLines; + + // Copy the current state settings of other into the current state. + currentState.copyStateFrom(other.currentState); + } + /** * Provides the default functionality for formatting literal values. * @@ -456,7 +492,8 @@ public static String formatLiteral(Object value) { } /** - * Adds a custom formatter expression to the {@code CodeWriter}. + * Adds a custom formatter expression to the current state of the + * {@code CodeWriter}. * *

The provided {@code identifier} string must match the following ABNF: * @@ -468,11 +505,14 @@ public static String formatLiteral(Object value) { *

* * @param identifier Formatter identifier to associate with this formatter. - * @param formatter Formatter function that formats the given object as a String. + * @param formatFunction Formatter function that formats the given object as a String. + * The formatter is give the value to format as an object + * (use .toString to access the string contents) and the + * current indentation string of the CodeWriter. * @return Returns the CodeWriter. */ - public final CodeWriter putFormatter(char identifier, BiFunction formatter) { - this.formatter.putFormatter(identifier, formatter); + public final CodeWriter putFormatter(char identifier, BiFunction formatFunction) { + this.currentState.putFormatter(identifier, formatFunction); return this; } @@ -849,6 +889,15 @@ public final CodeWriter setNewline(char newline) { return setNewline(String.valueOf(newline)); } + /** + * Gets the character used to represent newlines in the current state. + * + * @return Returns the newline string. + */ + public String getNewline() { + return currentState.newline; + } + /** * Sets the text used for indentation (defaults to four spaces). * @@ -860,6 +909,15 @@ public final CodeWriter setIndentText(String indentText) { return this; } + /** + * Gets the text used for indentation (defaults to four spaces). + * + * @return Returns the indentation string. + */ + public final String getIndentText() { + return currentState.indentText; + } + /** * Enables the trimming of trailing spaces on a line. * @@ -880,6 +938,15 @@ public final CodeWriter trimTrailingSpaces(boolean trimTrailingSpaces) { return this; } + /** + * Returns true if the trailing spaces in the current state are trimmed. + * + * @return Returns the trailing spaces setting of the current state. + */ + public boolean getTrimTrailingSpaces() { + return currentState.trimTrailingSpaces; + } + /** * Ensures that no more than one blank line occurs in succession. * @@ -904,6 +971,18 @@ public final CodeWriter trimBlankLines(int trimBlankLines) { return this; } + /** + * Returns the number of allowed consecutive newlines that are not + * trimmed by the CodeWriter when written to a string. + * + * @return Returns the number of allowed consecutive newlines. -1 means + * that no newlines are trimmed. 0 allows no blank lines. 1 or more + * allows for no more than N consecutive blank lines. + */ + public int getTrimBlankLines() { + return trimBlankLines; + } + /** * Configures the CodeWriter to always append a newline at the end of * the text if one is not already present. @@ -922,7 +1001,7 @@ public final CodeWriter insertTrailingNewline() { * *

This setting is not captured as part of push/popState. * - * @param trailingNewline The newline behavior. True to add, false to strip. + * @param trailingNewline True if a newline is added. * * @return Returns the CodeWriter. */ @@ -931,6 +1010,16 @@ public final CodeWriter insertTrailingNewline(boolean trailingNewline) { return this; } + /** + * Checks if the CodeWriter inserts a trailing newline (if necessary) when + * converted to a string. + * + * @return The newline behavior (true to insert a trailing newline). + */ + public boolean getInsertTrailingNewline() { + return trailingNewline; + } + /** * Sets a prefix to prepend to every line after a new line is added * (except for an inserted trailing newline). @@ -943,6 +1032,16 @@ public final CodeWriter setNewlinePrefix(String newlinePrefix) { return this; } + /** + * Gets the prefix to prepend to every line after a new line is added + * (except for an inserted trailing newline). + * + * @return Returns the newline prefix string. + */ + public String getNewlinePrefix() { + return currentState.newlinePrefix; + } + /** * Indents all text one level. * @@ -963,6 +1062,15 @@ public final CodeWriter indent(int levels) { return this; } + /** + * Gets the indentation level of the current state. + * + * @return Returns the indentation level of the current state. + */ + public int getIndentLevel() { + return currentState.indentation; + } + /** * Removes one level of indentation from all lines. * @@ -1194,6 +1302,7 @@ public final CodeWriter writeWithNoFormatting(Object content) { * @see #putFormatter */ public final String format(Object content, Object... args) { + CodeFormatter formatter = currentState.getCodeFormatter(); return formatter.format(currentState.expressionStart, content, currentState.indentText, this, args); } @@ -1389,6 +1498,12 @@ private final class State { private String newline = "\n"; private char expressionStart = '$'; + /** The formatter of the parent state (null for the root state). */ + private CodeFormatter parentFormatter; + + /** The formatter of the current state (null until a formatter is added to the state). */ + private CodeFormatter stateFormatter; + private transient String sectionName; /** @@ -1409,12 +1524,22 @@ private final class State { private Map>> interceptors = MapUtils.of(); private transient boolean copiedInterceptors = false; - State() {} + State() { + // A state created without copying from another needs a root formatter that + // has all of the default formatter functions registered. + stateFormatter = new CodeFormatter(); + DEFAULT_FORMATTERS.forEach(stateFormatter::putFormatter); + } + + @SuppressWarnings("CopyConstructorMissesField") + State(State copy) { + copyStateFrom(copy); + this.builder = copy.builder; + } - private State(State copy) { + private void copyStateFrom(State copy) { this.newline = copy.newline; this.expressionStart = copy.expressionStart; - this.builder = copy.builder; this.context = copy.context; this.indentText = copy.indentText; this.leadingIndentString = copy.leadingIndentString; @@ -1423,6 +1548,9 @@ private State(State copy) { this.trimTrailingSpaces = copy.trimTrailingSpaces; this.interceptors = copy.interceptors; this.disableNewline = copy.disableNewline; + + // Copy the resolved formatter of "copy" as the parent formatter of this State. + this.parentFormatter = copy.getCodeFormatter(); } @Override @@ -1430,6 +1558,18 @@ public String toString() { return builder == null ? "" : builder.toString(); } + private CodeFormatter getCodeFormatter() { + return stateFormatter != null ? stateFormatter : parentFormatter; + } + + private void putFormatter(char identifier, BiFunction formatFunction) { + if (stateFormatter == null) { + stateFormatter = new CodeFormatter(parentFormatter); + } + + stateFormatter.putFormatter(identifier, formatFunction); + } + private void mutateContext() { if (!copiedContext) { context = new HashMap<>(context); 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 ed6492013e1..d58c8fbd636 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 @@ -821,4 +821,75 @@ public void canUnwriteWithTemplatesThatExpandToNothing() { assertThat(writer.toString(), equalTo("Hi.Hello")); } + + @Test + public void formattersOnRootStateWorkOnAllStates() { + CodeWriter writer = new CodeWriter(); + writer.putFormatter('X', (value, indent) -> value.toString().toUpperCase(Locale.ENGLISH)); + writer.writeInline("$X", "hi"); + + writer.pushState(); + writer.writeInline(" $X", "there"); + writer.popState(); + + assertThat(writer.toString(), equalTo("HI THERE\n")); + } + + @Test + public void formattersArePerState() { + CodeWriter writer = new CodeWriter(); + // X is uppercase in all states unless overridden. + writer.putFormatter('X', (value, indent) -> value.toString().toUpperCase(Locale.ENGLISH)); + writer.writeInline("$X", "salutations"); + + writer.pushState(); + // Make X lowercase in this state. + writer.putFormatter('X', (value, indent) -> value.toString().toLowerCase(Locale.ENGLISH)); + // Add Y but only to this state. + writer.putFormatter('Y', (value, indent) -> value.toString()); + writer.writeInline(" $X $Y", "AND", "gReEtInGs"); + writer.popState(); + + // Ensure that X is restored to writing uppercase. + writer.writeInline("$X", ", friend"); + + assertThat(writer.toString(), equalTo("SALUTATIONS and gReEtInGs, FRIEND\n")); + } + + @Test + public void canCopySettingsIntoWriter() { + CodeWriter a = new CodeWriter(); + a.setNewline("\r\n"); + a.setExpressionStart('#'); + a.setIndentText(" "); + a.setNewlinePrefix("."); + a.trimTrailingSpaces(true); + a.trimBlankLines(2); + a.insertTrailingNewline(false); + + CodeWriter b = new CodeWriter(); + b.copySettingsFrom(a); + b.indent(); + + assertThat(b.getExpressionStart(), equalTo('#')); + assertThat(b.getNewline(), equalTo("\r\n")); + assertThat(b.getIndentText(), equalTo(" ")); + assertThat(b.getNewlinePrefix(), equalTo(".")); + assertThat(b.getTrimTrailingSpaces(), equalTo(true)); + assertThat(b.getTrimBlankLines(), equalTo(2)); + assertThat(b.getInsertTrailingNewline(), equalTo(false)); + assertThat(b.getIndentLevel(), equalTo(1)); + assertThat(a.getIndentLevel(), equalTo(0)); + } + + @Test + public void copyingSettingsDoesNotMutateOtherWriter() { + CodeWriter a = new CodeWriter(); + CodeWriter b = new CodeWriter(); + b.copySettingsFrom(a); + b.writeInline("Hello"); + + assertThat(b.toString(), equalTo("Hello\n")); + assertThat(a.toString(), equalTo("\n")); + } }