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 811b55bd4f2..a963a04fe4d 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 @@ -15,369 +15,346 @@ package software.amazon.smithy.utils; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import java.util.function.BiFunction; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Pattern; -/** - * TODO: Rewrite the formatter parser to use a custom {@link SimpleParser}. - */ +@SmithyInternalApi final class CodeFormatter { - private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z]+[a-zA-Z0-9_.#$]*$"); - private static final Set VALID_FORMATTER_CHARS = SetUtils.of( - '!', '#', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@', - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', - 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', ']', '^', '_', '`', '{', '|', '}', '~'); - - private final Map> formatters = new HashMap<>(); - private final CodeFormatter parentFormatter; - - 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; - } + private CodeFormatter() {} - void putFormatter(Character identifier, BiFunction formatFunction) { - if (!VALID_FORMATTER_CHARS.contains(identifier)) { - throw new IllegalArgumentException("Invalid formatter identifier: " + identifier); + static void run(Appendable sink, CodeWriter writer, String template, Object[] args) { + ColumnTrackingAppendable wrappedSink = new ColumnTrackingAppendable(sink); + List program = new Parser(writer, template, args).parse(); + try { + for (Operation op : program) { + op.apply(wrappedSink, writer, wrappedSink.column); + } + } catch (IOException e) { + throw new RuntimeException("Error appending to CodeWriter template: " + e, e); } - - 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); + private abstract static class DecoratedAppendable implements Appendable { + @Override + public Appendable append(CharSequence csq) throws IOException { + return append(csq, 0, csq.length()); } - return result; + @Override + public Appendable append(CharSequence csq, int start, int end) throws IOException { + for (int i = start; i < end; i++) { + append(csq.charAt(i)); + } + return this; + } } - String format(CodeWriter writer, Object content, Object... args) { - String expression = String.valueOf(content); - char expressionStart = writer.getExpressionStart(); - String indent = writer.getIndentText(); + private static final class ColumnTrackingAppendable extends DecoratedAppendable { + int column = 0; + private final Appendable delegate; - // Simple case of no arguments and no expressions. - if (args.length == 0 && expression.indexOf(expressionStart) == -1) { - return expression; + ColumnTrackingAppendable(Appendable delegate) { + this.delegate = delegate; } - return parse(writer, new State(content, indent, writer, args)); - } - - private String parse(CodeWriter writer, State state) { - char expressionStart = writer.getExpressionStart(); - - while (!state.eof()) { - char c = state.c(); - state.next(); - if (c == expressionStart) { - parseArgumentWrapper(writer, state); + @Override + public Appendable append(char c) throws IOException { + if (c == '\r' || c == '\n') { + column = 0; } else { - state.append(c); + column++; } + delegate.append(c); + return this; } - - if (state.relativeIndex == -1) { - ensureAllPositionalArgumentsWereUsed(writer, state.expression, state.positionals); - } else if (state.relativeIndex < state.args.length) { - throw error(writer, String.format( - "Found %d unused relative format arguments: %s", - state.args.length - state.relativeIndex, state.expression)); - } - - return state.result.toString(); } - // Provides debug context in each thrown error. - public static IllegalArgumentException error(CodeWriter writer, String message) { - return new IllegalArgumentException(message + " " + writer.getDebugInfo()); - } + @FunctionalInterface + private interface Operation { + void apply(Appendable sink, CodeWriter writer, int column) throws IOException; - private void parseArgumentWrapper(CodeWriter writer, State state) { - if (state.eof()) { - throw error(writer, "Invalid format string: " + state); + // Writes literal segments of the input string. + static Operation stringSlice(String source, int start, int end) { + return (sink, writer, column) -> sink.append(source, start, end); } - char expressionStart = writer.getExpressionStart(); - char c = state.c(); - if (c == expressionStart) { - // $$ -> $ - state.append(expressionStart); - state.next(); - } else if (c == '{') { - parseBracedArgument(writer, state); - } else { - parseArgument(writer, state, -1); + // An operation that writes a resolved variable to the writer (e.g., positional arguments). + static Operation staticValue(String value) { + return (sink, writer, column) -> sink.append(value); } - } - - private void parseBracedArgument(CodeWriter writer, State state) { - int startingBraceColumn = state.column; - state.next(); // Skip "{" - parseArgument(writer, state, startingBraceColumn); - if (state.eof() || state.c() != '}') { - throw error(writer, "Unclosed expression argument: " + state); + // Expands inline sections. + static Operation inlineSection(String sectionName, Operation delegate) { + return (sink, writer, column) -> { + StringBuilder buffer = new StringBuilder(); + delegate.apply(buffer, writer, column); + sink.append(writer.expandSection(sectionName, buffer.toString(), writer::writeWithNoFormatting)); + }; } - state.next(); // Skip "}" - } - - private void parseArgument(CodeWriter writer, State state, int startingBraceColumn) { - if (state.eof()) { - throw error(writer, "Invalid format string: " + state); - } - - char c = state.c(); - if (Character.isLowerCase(c)) { - parseNamedArgument(writer, state, startingBraceColumn); - } else if (Character.isDigit(c)) { - parsePositionalArgument(writer, state, startingBraceColumn); - } else { - parseRelativeArgument(writer, state, startingBraceColumn); + // Used for "|". Wraps another operation and ensures newlines are properly indented. + static Operation block(Operation delegate, String staticWhitespace) { + return (sink, writer, column) -> delegate.apply( + new BlockAppender(sink, column, staticWhitespace), writer, column); } } - private void parseNamedArgument(CodeWriter writer, State state, int startingBraceColumn) { - // Expand a named context value: "$" key ":" identifier - String name = parseNameUntil(writer, state, ':'); - state.next(); + private static final class BlockAppender extends DecoratedAppendable { + private final Appendable delegate; + private final int spaces; + private final String staticWhitespace; + private boolean previousIsCarriageReturn; - // Consume the character after the colon. - if (state.eof()) { - throw error(writer, "Expected an identifier after the ':' in a named argument: " + state); + BlockAppender(Appendable delegate, int spaces, String staticWhitespace) { + this.delegate = delegate; + this.spaces = spaces; + this.staticWhitespace = staticWhitespace; } - char identifier = consumeFormatterIdentifier(state); - state.append(applyFormatter(writer, state, identifier, state.writer.getContext(name), startingBraceColumn)); - } - - private char consumeFormatterIdentifier(State state) { - char identifier = state.c(); - state.next(); - return identifier; - } + @Override + public Appendable append(char c) throws IOException { + if (c == '\n') { + delegate.append('\n'); + writeSpaces(); + previousIsCarriageReturn = false; + } else { + if (previousIsCarriageReturn) { + writeSpaces(); + } + previousIsCarriageReturn = c == '\r'; + delegate.append(c); + } - private void parsePositionalArgument(CodeWriter writer, State state, int startingBraceColumn) { - // Expand a positional argument: "$" 1*digit identifier - expectConsistentRelativePositionals(writer, state, state.relativeIndex <= 0); - state.relativeIndex = -1; - int startPosition = state.position; - while (state.next() && Character.isDigit(state.c())); - int index = Integer.parseInt(state.expression.substring(startPosition, state.position)) - 1; - - if (index < 0 || index >= state.args.length) { - throw error(writer, String.format( - "Positional argument index %d out of range of provided %d arguments in " - + "format string: %s", index, state.args.length, state)); + return this; } - Object arg = getPositionalArgument(writer, state.expression, index, state.args); - state.positionals[index] = true; - char identifier = consumeFormatterIdentifier(state); - state.append(applyFormatter(writer, state, identifier, arg, startingBraceColumn)); + private void writeSpaces() throws IOException { + if (staticWhitespace != null) { + delegate.append(staticWhitespace); + } else { + for (int i = 0; i < spaces; i++) { + delegate.append(' '); + } + } + } } - private void parseRelativeArgument(CodeWriter writer, State state, int startingBraceColumn) { - // Expand to a relative argument. - expectConsistentRelativePositionals(writer, state, state.relativeIndex > -1); - state.relativeIndex++; - Object argument = getPositionalArgument(writer, state.expression, state.relativeIndex - 1, state.args); - char identifier = consumeFormatterIdentifier(state); - state.append(applyFormatter(writer, state, identifier, argument, startingBraceColumn)); - } + private static final class Parser { + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z]+[a-zA-Z0-9_.#$]*$"); - private String parseNameUntil(CodeWriter writer, State state, char endToken) { - int endIndex = state.expression.indexOf(endToken, state.position); + private final String template; + private final SimpleParser parser; + private final char expressionStart; + private final CodeWriter writer; + private final Object[] arguments; + private final boolean[] positionals; + private final List operations = new ArrayList<>(); + private int relativeIndex = 0; - if (endIndex == -1) { - throw error(writer, "Invalid named format argument: " + state); + Parser(CodeWriter writer, String template, Object[] arguments) { + this.template = template; + this.writer = writer; + this.expressionStart = writer.getExpressionStart(); + this.parser = new SimpleParser(template); + this.arguments = arguments; + this.positionals = new boolean[arguments.length]; } - String name = state.expression.substring(state.position, endIndex); - ensureNameIsValid(writer, state, name); - state.position = endIndex; - return name; - } + private void pushOperation(Operation op) { + operations.add(op); + } - private static void expectConsistentRelativePositionals(CodeWriter writer, State state, boolean expectation) { - if (!expectation) { - throw error(writer, "Cannot mix positional and relative arguments: " + state); + private RuntimeException error(String message) { + return parser.syntax(message + " (template: " + template + ") " + writer.getDebugInfo()); } - } - private static void ensureAllPositionalArgumentsWereUsed( - CodeWriter writer, - String expression, - boolean[] positionals - ) { - int unused = 0; + private List parse() { + boolean parsingLiteral = false; + int literalStartCharacter = 0; + + while (!parser.eof()) { + char c = parser.peek(); + parser.skip(); + + if (c != expressionStart) { + parsingLiteral = true; + } else if (parser.peek() == expressionStart) { + // Don't write escaped expression starts. + pushOperation(Operation.stringSlice(template, literalStartCharacter, parser.position())); + parser.expect(expressionStart); + parsingLiteral = true; + literalStartCharacter = parser.position(); + } else { + if (parsingLiteral) { + // If previously parsing literal text, then add that to the operation (not including '$'). + pushOperation(Operation.stringSlice(template, literalStartCharacter, parser.position() - 1)); + parsingLiteral = false; + } + pushOperation(parseArgument()); + literalStartCharacter = parser.position(); + } + } - for (boolean b : positionals) { - if (!b) { - unused++; + if (parsingLiteral && literalStartCharacter < parser.position() && parser.position() > 0) { + pushOperation(Operation.stringSlice(template, literalStartCharacter, parser.position())); } - } - if (unused > 0) { - throw error(writer, String.format( - "Found %d unused positional format arguments: %s", unused, expression)); - } - } + if (relativeIndex == -1) { + ensureAllPositionalArgumentsWereUsed(); + } else if (relativeIndex < arguments.length) { + int unusedCount = arguments.length - relativeIndex; + throw error(String.format("Found %d unused relative format arguments", unusedCount)); + } - private Object getPositionalArgument(CodeWriter writer, String content, int index, Object[] args) { - if (index >= args.length) { - throw error(writer, String.format( - "Given %d arguments but attempted to format index %d: %s", args.length, index, content)); + return operations; } - return args[index]; - } + private void ensureAllPositionalArgumentsWereUsed() { + int unused = 0; + for (boolean b : positionals) { + if (!b) { + unused++; + } + } + if (unused > 0) { + throw error(String.format("Found %d unused positional format arguments", unused)); + } + } - private String applyFormatter( - CodeWriter writer, - State state, - char formatter, - Object argument, - int startingBraceColumn - ) { - BiFunction formatFunction = getFormatter(formatter); - - if (formatFunction == null) { - throw error(writer, String.format( - "Unknown formatter `%s` found in format string: %s", formatter, state)); + private Operation parseArgument() { + return parser.peek() == '{' ? parseBracedArgument() : parseNormalArgument(); } - String result = formatFunction.apply(argument, state.indent); + private Operation parseBracedArgument() { + // Track the starting position of the interpolation (here minus the opening '$'). + int startPosition = parser.position() - 1; + int startColumn = parser.column() - 2; + + parser.expect('{'); + Operation operation = parseNormalArgument(); + + if (parser.peek() == '@') { + parser.skip(); + int start = parser.position(); + parser.consumeUntilNoLongerMatches(c -> c != '}' && c != '|'); + String sectionName = parser.sliceFrom(start); + ensureNameIsValid(sectionName); + operation = Operation.inlineSection(sectionName, operation); + } - if (!state.eof() && state.c() == '@') { - if (startingBraceColumn == -1) { - throw error(writer, "Inline blocks can only be created inside braces: " + state); + if (parser.peek() == '|') { + String staticWhitespace = isAllLeadingWhitespaceOnLine(startPosition, startColumn) + ? template.substring(startPosition - startColumn, startPosition) + : null; + parser.expect('|'); + operation = Operation.block(operation, staticWhitespace); } - result = expandInlineSection(writer, state, result); + + parser.expect('}'); + + return operation; } - // Only look for alignment when inside a brace interpolation. - if (startingBraceColumn != -1 && !state.eof() && state.c() == '|') { - state.next(); // skip '|', which should precede '}'. - - String repeated = StringUtils.repeat(' ', startingBraceColumn); - StringBuilder aligned = new StringBuilder(); - - for (int i = 0; i < result.length(); i++) { - char c = result.charAt(i); - if (c == '\n') { - aligned.append('\n').append(repeated); - } else if (c == '\r') { - aligned.append('\r'); - if (i + 1 < result.length() && result.charAt(i + 1) == '\n') { - aligned.append('\n'); - i++; // Skip \n - } - aligned.append(repeated); - } else { - aligned.append(c); + private boolean isAllLeadingWhitespaceOnLine(int startPosition, int startColumn) { + for (int i = startPosition - startColumn; i < startPosition; i++) { + char ch = template.charAt(i); + if (ch != ' ' && ch != '\t') { + return false; } } - result = aligned.toString(); + return true; } - return result; - } + private Operation parseNormalArgument() { + char c = parser.peek(); - private String expandInlineSection(CodeWriter writer, State state, String argument) { - state.next(); // Skip "@" - String sectionName = parseNameUntil(writer, state, '}'); - ensureNameIsValid(writer, state, sectionName); - return state.writer.expandSection(sectionName, argument, s -> state.writer.write(s)); - } + Object value; + if (Character.isLowerCase(c)) { + value = parseNamedArgument(); + } else if (Character.isDigit(c)) { + value = parsePositionalArgument(); + } else { + value = parseRelativeArgument(); + } - private static void ensureNameIsValid(CodeWriter writer, State state, String name) { - if (!NAME_PATTERN.matcher(name).matches()) { - throw error(writer, String.format( - "Invalid format expression name `%s` at position %d of: %s", - name, state.position + 1, state)); + // Parse the formatter and apply it. + String formatted = consumeAndApplyFormatterIdentifier(value); + return Operation.staticValue(formatted); } - } - private static final class State { - StringBuilder result = new StringBuilder(); - int position = 0; - int relativeIndex = 0; - CodeWriter writer; - String expression; - String indent; - Object[] args; - boolean[] positionals; - int column = 0; + private Object parseNamedArgument() { + // Expand a named context value: "$" key ":" identifier + int start = parser.position(); + parser.consumeUntilNoLongerMatches(c -> c != ':'); + String name = parser.sliceFrom(start); + ensureNameIsValid(name); - State(Object expression, String indent, CodeWriter writer, Object[] args) { - this.expression = String.valueOf(expression); - this.indent = indent; - this.writer = writer; - this.args = args; - this.positionals = new boolean[args.length]; - } + parser.expect(':'); + + // Consume the character after the colon. + if (parser.eof()) { + throw error("Expected an identifier after the ':' in a named argument"); + } - char c() { - return expression.charAt(position); + return writer.getContext(name); } - boolean eof() { - return position >= expression.length(); + private String consumeAndApplyFormatterIdentifier(Object value) { + char identifier = parser.expect(CodeWriterFormatterContainer.VALID_FORMATTER_CHARS); + String result = writer.applyFormatter(identifier, value); + if (result == null) { + throw error(String.format("Unknown formatter `%c` found in format string", identifier)); + } + return result; } - boolean next() { - return ++position < expression.length() - 1; + private Object parseRelativeArgument() { + if (relativeIndex == -1) { + throw error("Cannot mix positional and relative arguments"); + } + + relativeIndex++; + return getPositionalArgument(relativeIndex - 1); } - void append(char c) { - if (c == '\r' || c == '\n') { - column = 0; + private Object getPositionalArgument(int index) { + if (index >= arguments.length) { + throw error(String.format("Given %d arguments but attempted to format index %d", + arguments.length, index)); } else { - column++; + // Track the usage of the positional argument. + positionals[index] = true; + return arguments[index]; } - result.append(c); } - void append(String string) { - int lastNewline = string.lastIndexOf('\n'); - if (lastNewline == -1) { - lastNewline = string.lastIndexOf('\r'); + private Object parsePositionalArgument() { + // Expand a positional argument: "$" 1*digit identifier + if (relativeIndex > 0) { + throw error("Cannot mix positional and relative arguments"); } - if (lastNewline == -1) { - // if no newline was found, then the column is the length of the string. - column = string.length(); - } else { - // Otherwise, it's the length minus the last newline. - column = string.length() - lastNewline; + + relativeIndex = -1; + int startPosition = parser.position(); + parser.consumeUntilNoLongerMatches(Character::isDigit); + int index = Integer.parseInt(parser.sliceFrom(startPosition)) - 1; + + if (index < 0 || index >= arguments.length) { + throw error(String.format( + "Positional argument index %d out of range of provided %d arguments in format string", + index, arguments.length)); } - result.append(string); + + return getPositionalArgument(index); } - @Override - public String toString() { - return expression; + private void ensureNameIsValid(String name) { + if (!NAME_PATTERN.matcher(name).matches()) { + throw error(String.format("Invalid format expression name `%s`", name)); + } } } } 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 47d9c15ff82..5f0713b0f9b 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 @@ -22,6 +22,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.StringJoiner; import java.util.function.BiFunction; @@ -73,13 +74,27 @@ * formatters: * *
    - *
  • {@code L}: Outputs a literal value of an {@code Object} using + *
  • {@code L} (literal): Outputs a literal value of an {@code Object} using * the following implementation: (1) A null value is formatted as "". * (2) An empty {@code Optional} value is formatted as "". (3) A non-empty * {@code Optional} value is recursively formatted using the value inside * of the {@code Optional}. (3) All other valeus are formatted using the * result of calling {@link String#valueOf}.
  • - *
  • {@code S}: Adds double quotes around the result of formatting a + * + *
  • {@code C} (call): Runs a {@link Runnable} or {@link Consumer} argument + * that is expected to write to the same writer. Any text written to the CodeWriter + * inside of the Runnable is used as the value of the argument. Note that a + * single trailing newline is removed from the captured text. If a Runnable is + * provided, it is required to have a reference to the CodeWriter. A Consumer + * is provided a reference to the CodeWriter as a single argument. + * + *
    {@code
    + *     CodeWriter writer = new CodeWriter();
    + *     writer.write("Hello, $C.", () -> writer.write("there"));
    + *     assert(writer.toString().equals("Hello, there.\n"));
    + *     }
  • + * + *
  • {@code S} (string): Adds double quotes around the result of formatting a * value first using the default literal "L" implementation described * above and then wrapping the value in an escaped string safe for use in * Java according to https://docs.oracle.com/javase/specs/jls/se7/html/jls-3.html#jls-3.10.6. @@ -92,13 +107,14 @@ * 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: + *

    The identifier given to a formatter must match one of the following + * characters: * *

    - * %x21-23    ; ( '!' - '#' )
    - * / %x25-2F  ; ( '%' - '/' )
    - * / %x3A-60  ; ( ':' - '`' )
    - * / %x7B-7E  ; ( '{' - '~' )
    + *    "!" / "#" / "%" / "&" / "*" / "+" / "," / "-" / "." / "/" / ";"
    + *  / "=" / "?" / "@" / "A" / "B" / "C" / "D" / "E" / "F" / "G" / "H"
    + *  / "I" / "J" / "K" / "L" / "M" / "N" / "O" / "P" / "Q" / "R" / "S"
    + *  / "T" / "U" / "V" / "W" / "X" / "Y" / "Z" / "^" / "_" / "`" / "~"
      * 
    * *

    Relative parameters

    @@ -385,6 +401,33 @@ * System.out.println(writer.toString()); * // Outputs: "Names: Bob\n Karen\n Luis\n" * } + * + *

    Alignment occurs either statically or dynamically based on the characters + * that come before interpolation. If all of the characters in the literal + * template that come before interpolation are spaces and tabs, then those + * characters are used when indenting newlines. Otherwise, the number of + * characters written as the template result that come before interpolation + * are used when indenting (this takes into account any interpolation that + * may precede block interpolation). + * + *

    Block interpolation is particularly used when using text blocks in Java + * because it allows templates to more closely match their end result. + * + *

    {@code
    + * // Assume handleNull, handleA, and handleB are Runnable.
    + * writer.write("""
    + *     if (foo == null) {
    + *         ${C|}
    + *     } else if (foo == "a") {
    + *         ${C|}
    + *     } else if (foo == "b") {
    + *         ${C|}
    + *     }
    + *     """,
    + *     handleNull,
    + *     handleA,
    + *     handleB);
    + * }
    */ public class CodeWriter { private static final Pattern LINES = Pattern.compile("\\r?\\n"); @@ -486,7 +529,7 @@ public static String formatLiteral(Object value) { if (value == null) { return ""; } else if (value instanceof Optional) { - Optional optional = (Optional) value; + Optional optional = (Optional) value; return optional.isPresent() ? formatLiteral(optional.get()) : ""; } else { return String.valueOf(value); @@ -1383,7 +1426,9 @@ public final CodeWriter writeWithNoFormatting(Object content) { * @see #putFormatter */ public final String format(Object content, Object... args) { - return currentState.getCodeFormatter().format(this, content, args); + StringBuilder result = new StringBuilder(); + CodeFormatter.run(result, this, Objects.requireNonNull(content).toString(), args); + return result.toString(); } /** @@ -1591,6 +1636,33 @@ String expandSection(String sectionName, String defaultContent, Consumer return buffer.toString(); } + // Used only by CodeFormatter to apply formatters. + @SuppressWarnings("unchecked") + String applyFormatter(char identifier, Object value) { + BiFunction f = currentState.getCodeFormatterContainer().getFormatter(identifier); + if (f != null) { + return f.apply(value, getIndentText()); + } else if (identifier == 'C') { + // The default C formatter is evaluated dynamically to prevent cyclic references. + String sectionName = "__anonymous_inline_" + states.size(); + if (value instanceof Runnable) { + Runnable runnable = (Runnable) value; + return expandSection(sectionName, "", ignore -> runnable.run()); + } else if (value instanceof Consumer) { + Consumer consumer = (Consumer) value; + return expandSection(sectionName, "", ignore -> consumer.accept(this)); + } else { + throw new ClassCastException(String.format( + "Expected CodeWriter value for 'C' formatter to be an instance of %s or %s, but found %s %s", + Runnable.class.getName(), Consumer.class.getName(), + value.getClass().getName(), getDebugInfo())); + } + } else { + // Return null if no formatter was found. + return null; + } + } + private final class State { private String indentText = " "; private String leadingIndentString = ""; @@ -1602,10 +1674,10 @@ private final class State { private char expressionStart = '$'; /** The formatter of the parent state (null for the root state). */ - private CodeFormatter parentFormatter; + private CodeWriterFormatterContainer parentFormatter; /** The formatter of the current state (null until a formatter is added to the state). */ - private CodeFormatter stateFormatter; + private CodeWriterFormatterContainer stateFormatter; private transient String sectionName; @@ -1630,7 +1702,7 @@ private final class 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(); + stateFormatter = new CodeWriterFormatterContainer(); DEFAULT_FORMATTERS.forEach(stateFormatter::putFormatter); } @@ -1653,7 +1725,7 @@ private void copyStateFrom(State copy) { this.disableNewline = copy.disableNewline; // Copy the resolved formatter of "copy" as the parent formatter of this State. - this.parentFormatter = copy.getCodeFormatter(); + this.parentFormatter = copy.getCodeFormatterContainer(); } @Override @@ -1661,13 +1733,13 @@ public String toString() { return builder == null ? "" : builder.toString(); } - private CodeFormatter getCodeFormatter() { + private CodeWriterFormatterContainer getCodeFormatterContainer() { return stateFormatter != null ? stateFormatter : parentFormatter; } private void putFormatter(char identifier, BiFunction formatFunction) { if (stateFormatter == null) { - stateFormatter = new CodeFormatter(parentFormatter); + stateFormatter = new CodeWriterFormatterContainer(parentFormatter); } stateFormatter.putFormatter(identifier, formatFunction); diff --git a/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriterFormatterContainer.java b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriterFormatterContainer.java new file mode 100644 index 00000000000..6109911f2b3 --- /dev/null +++ b/smithy-utils/src/main/java/software/amazon/smithy/utils/CodeWriterFormatterContainer.java @@ -0,0 +1,69 @@ +/* + * Copyright 2022 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.utils; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * A container for formatters registered with CodeWriter. + */ +@SmithyInternalApi +final class CodeWriterFormatterContainer { + + static final char[] VALID_FORMATTER_CHARS = { + '!', '#', '%', '&', '*', '+', ',', '-', '.', '/', ';', '=', '?', '@', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '^', '_', '`', '~'}; + + private final Map> formatters = new HashMap<>(); + private final CodeWriterFormatterContainer parent; + + CodeWriterFormatterContainer() { + this(null); + } + + CodeWriterFormatterContainer(CodeWriterFormatterContainer parent) { + this.parent = parent; + } + + void putFormatter(Character identifier, BiFunction formatFunction) { + boolean matched = false; + for (char c : VALID_FORMATTER_CHARS) { + if (c == identifier) { + matched = true; + break; + } + } + + if (!matched) { + throw new IllegalArgumentException("Invalid formatter identifier: " + identifier); + } + + formatters.put(identifier, formatFunction); + } + + BiFunction getFormatter(char identifier) { + BiFunction result = formatters.get(identifier); + + if (result == null && parent != null) { + result = parent.getFormatter(identifier); + } + + return result; + } +} 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 34d848859c5..ea9fb1fb719 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 @@ -19,6 +19,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import java.util.function.Consumer; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -35,9 +36,9 @@ private static String valueOf(Object value, String indent) { @Test public void formatsDollarLiterals() { CodeWriter writer = createWriter(); - String result = writer.format("hello $$"); + String result = writer.format("hello $$."); - assertThat(result, equalTo("hello $")); + assertThat(result, equalTo("hello $.")); } @Test @@ -60,17 +61,17 @@ public void formatsRelativeLiteralsInBraces() { @Test public void requiresTextAfterOpeningBrace() { - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> { + RuntimeException e = Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.format("hello ${", "there"); }); - assertThat(e.getMessage(), containsString("Invalid format string: hello ${ (Debug Info {path=ROOT, near=})")); + assertThat(e.getMessage(), containsString("expected one of the following tokens: '!'")); } @Test public void requiresBraceIsClosed() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello ${L .", "there"); @@ -97,7 +98,7 @@ public void formatsMultipleRelativeLiteralsInBraces() { @Test public void ensuresAllRelativeArgumentsWereUsed() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $L", "a", "b", "c"); @@ -106,7 +107,7 @@ public void ensuresAllRelativeArgumentsWereUsed() { @Test public void performsRelativeBoundsChecking() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $L"); @@ -115,7 +116,7 @@ public void performsRelativeBoundsChecking() { @Test public void validatesThatDollarIsNotAtEof() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $"); @@ -124,7 +125,7 @@ public void validatesThatDollarIsNotAtEof() { @Test public void validatesThatCustomStartIsNotAtEof() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.setExpressionStart('#'); writer.format("hello #"); @@ -180,7 +181,7 @@ public void formatsMultipleDigitPositionalLiterals() { @Test public void performsPositionalBoundsChecking() { - IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> { + RuntimeException e = Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.write("Foo!"); writer.putFormatter('L', CodeFormatterTest::valueOf); @@ -188,13 +189,12 @@ public void performsPositionalBoundsChecking() { }); assertThat(e.getMessage(), containsString("Positional argument index 0 out of range of provided 0 arguments " - + "in format string: hello $1L " - + "(Debug Info {path=ROOT, near=Foo!\\n})")); + + "in format string")); } @Test public void performsPositionalBoundsCheckingNotZero() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $0L", "a"); @@ -203,7 +203,7 @@ public void performsPositionalBoundsCheckingNotZero() { @Test public void validatesThatPositionalIsNotAtEof() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $2"); @@ -212,7 +212,7 @@ public void validatesThatPositionalIsNotAtEof() { @Test public void validatesThatAllPositionalsAreUsed() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $2L $3L", "a", "b", "c", "d"); @@ -221,7 +221,7 @@ public void validatesThatAllPositionalsAreUsed() { @Test public void cannotMixPositionalAndRelative() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $1L, $L", "there"); @@ -230,7 +230,7 @@ public void cannotMixPositionalAndRelative() { @Test public void cannotMixRelativeAndPositional() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $L, $1L", "there"); @@ -261,7 +261,7 @@ public void formatsNamedValuesInBraces() { @Test public void ensuresNamedValuesHasColon() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $abc foo"); @@ -270,7 +270,7 @@ public void ensuresNamedValuesHasColon() { @Test public void ensuresNamedValuesHasFormatterAfterColon() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("hello $abc:"); @@ -289,7 +289,7 @@ public void allowsSeveralSpecialCharactersInNamedArguments() { @Test public void ensuresNamedValuesMatchRegex() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('L', CodeFormatterTest::valueOf); writer.format("$nope!:L"); @@ -298,7 +298,7 @@ public void ensuresNamedValuesMatchRegex() { @Test public void formattersMustNotBeLowercase() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('a', CodeFormatterTest::valueOf); }); @@ -306,7 +306,7 @@ public void formattersMustNotBeLowercase() { @Test public void formattersMustNotBeNumbers() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('1', CodeFormatterTest::valueOf); }); @@ -314,7 +314,7 @@ public void formattersMustNotBeNumbers() { @Test public void formattersMustNotBeDollar() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.putFormatter('$', CodeFormatterTest::valueOf); }); @@ -322,7 +322,7 @@ public void formattersMustNotBeDollar() { @Test public void ensuresFormatterIsValid() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.format("$E", "hi"); }); @@ -372,15 +372,14 @@ public void canUseOtherFormattersWithSections() { @Test public void cannotExpandInlineSectionOutsideOfBrace() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { - CodeWriter writer = createWriter(); - writer.write("Foo $L@hello baz", "default"); - }); + CodeWriter writer = createWriter(); + writer.write("Foo $L@hello baz", "default"); + assertThat(writer.toString(), equalTo("Foo default@hello baz\n")); } @Test public void inlineSectionNamesMustBeValid() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.write("${L@foo!}", "default"); }); @@ -396,7 +395,7 @@ public void inlineAlignmentMustOccurInBraces() { @Test public void detectsBlockAlignmentEof() { - Assertions.assertThrows(IllegalArgumentException.class, () -> { + Assertions.assertThrows(RuntimeException.class, () -> { CodeWriter writer = createWriter(); writer.write("${L|", "default"); }); @@ -454,4 +453,77 @@ public void alignedBlocksComposeWithPrefixes() { assertThat(writer.toString(), equalTo("| method() {\n| // this\n| // is a test.\n| }\n")); } + + @Test + public void defaultCFormatterRequiresRunnableOrFunction() { + RuntimeException e = Assertions.assertThrows(RuntimeException.class, () -> new CodeWriter().write("$C", "hi")); + + assertThat(e.getMessage(), containsString( + "Expected CodeWriter value for 'C' formatter to be an instance of " + Runnable.class.getName() + + " or " + Consumer.class.getName() + ", but found " + String.class.getName())); + } + + @Test + public void cFormaterAcceptsConsumersThatAreCodeWriters() { + CodeWriter w = new CodeWriter(); + w.write("$C", (Consumer) writer -> writer.write("Hello!")); + + assertThat(w.toString(), equalTo("Hello!\n")); + } + + @Test + public void cFormatterAcceptsConsumersThatAreSubtypesOfCodeWriters() { + CodeWriterSubtype w = new CodeWriterSubtype(); + w.write("$C", (Consumer) writer -> writer.write2("Hello!")); + + assertThat(w.toString(), equalTo("Hello!\n")); + } + + // This class makes sure that subtypes of CodeWriter can be called from the C + // formatter using an unsafe cast. + static final class CodeWriterSubtype extends CodeWriter { + void write2(String text) { + write(text); + } + } + + @Test + public void alignsBlocksWithStaticWhitespace() { + CodeWriter writer = new CodeWriter(); + writer.write("$1L() {\n" + + "\t\t${2L|}\n" + + "}", "method", "hi\nthere"); + + assertThat(writer.toString(), equalTo("method() {\n\t\thi\n\t\tthere\n}\n")); + } + + @Test + public void alignsBlocksWithStaticAndSpecificWhitespace() { + CodeWriter writer = new CodeWriter(); + writer.write("$1L() {\n" + + "\t\t ${2L|}\n" + + "}", "method", "hi\nthere"); + + assertThat(writer.toString(), equalTo("method() {\n\t\t hi\n\t\t there\n}\n")); + } + + @Test + public void canAlignNestedBlocks() { + CodeWriter writer = new CodeWriter(); + writer.write("$L() {\n\t\t${C|}\n}", "a", (Runnable) () -> { + writer.write("$L() {\n\t\t${C|}\n}", "b", (Runnable) () -> { + writer.write("$L() {\n\t\t ${C|}\n}", "c", (Runnable) () -> { + writer.write("d"); + }); + }); + }); + + assertThat(writer.toString(), equalTo("a() {\n" + + "\t\tb() {\n" + + "\t\t\t\tc() {\n" + + "\t\t\t\t\t\t d\n" + + "\t\t\t\t}\n" + + "\t\t}\n" + + "}\n")); + } } 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 e31d6b45b55..dbf7c7f8471 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 @@ -952,4 +952,35 @@ public void copyingSettingsDoesNotMutateOtherWriter() { assertThat(b.toString(), equalTo("Hello\n")); assertThat(a.toString(), equalTo("\n")); } + + @Test + public void canPassRunnableToFormatters() { + CodeWriter writer = new CodeWriter(); + writer.write("Hi, $C.", (Runnable) () -> writer.write("TheName")); + assertThat(writer.toString(), equalTo("Hi, TheName.\n")); + } + + @Test + public void canPassRunnableToFormattersAndEvenCreateInlineSections() { + CodeWriter writer = new CodeWriter(); + + writer.onSection("Name", text -> { + writer.write(text + " (name)"); + }); + + writer.write("Hi, $C.", (Runnable) () -> { + writer.pushState("Name"); + writer.write("TheName"); + writer.popState(); + }); + + assertThat(writer.toString(), equalTo("Hi, TheName (name).\n")); + } + + @Test + public void canPassRunnableAndKeepTrailingNewline() { + CodeWriter writer = new CodeWriter(); + writer.write("Hi, $C.", (Runnable) () -> writer.write("TheName\n")); + assertThat(writer.toString(), equalTo("Hi, TheName\n.\n")); + } }