From ac7e907bf667e0d5ed04f9d9c129145c1de7346c Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 28 Sep 2020 22:24:48 -0700 Subject: [PATCH] Add CodegenWriter abstractions CodegenWriter, CodegenWriterDelegator, et al. are meant to make it easier to develop code generators that utilize Smithy's Symbol, SymbolReference, and SymbolDependency abstractions. CodegenWriterDelegator provides abstractions for creating and handing out references to CodegenWriters while collecting all of the imports needed for an entire project. CodegenWriter is used to write code, symbols, documentation, and manage the imports needed for a specific file. Other abstractions were added that make it easier to build-up and compose CodegenWriters and delegators, for example, the JavaStyleDocumentationWriter can make it easier to implement writing documentation for programming languages that use Java-style comments. --- .../codegen/core/writer/CodegenWriter.java | 262 ++++++++++++++++++ .../core/writer/CodegenWriterDelegator.java | 247 +++++++++++++++++ .../core/writer/CodegenWriterFactory.java | 57 ++++ .../core/writer/DocumentationWriter.java | 60 ++++ .../codegen/core/writer/ImportContainer.java | 57 ++++ .../JavaStyleDocumentationWriterBuilder.java | 144 ++++++++++ .../core/writer/UseShapeWriterObserver.java | 68 +++++ .../writer/CodegenWriterDelegatorTest.java | 158 +++++++++++ .../core/writer/CodegenWriterTest.java | 116 ++++++++ ...vaStyleDocumentationWriterBuilderTest.java | 98 +++++++ .../smithy/codegen/core/writer/MyWriter.java | 70 +++++ .../amazon/smithy/utils/CodeWriter.java | 44 +++ .../amazon/smithy/utils/CodeWriterTest.java | 12 + 13 files changed, 1393 insertions(+) create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriter.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegator.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterFactory.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/DocumentationWriter.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/ImportContainer.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilder.java create mode 100644 smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/UseShapeWriterObserver.java create mode 100644 smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegatorTest.java create mode 100644 smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterTest.java create mode 100644 smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilderTest.java create mode 100644 smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/MyWriter.java diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriter.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriter.java new file mode 100644 index 00000000000..d567542838f --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriter.java @@ -0,0 +1,262 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolContainer; +import software.amazon.smithy.codegen.core.SymbolDependency; +import software.amazon.smithy.codegen.core.SymbolDependencyContainer; +import software.amazon.smithy.codegen.core.SymbolReference; +import software.amazon.smithy.utils.CodeWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * A {@code CodeGenWriter} is a specialized {@link CodeWriter} that makes it + * easier to implement code generation that utilizes {@link Symbol}s and + * {@link SymbolDependency} values. + * + *

A {@code CodegenWriter} is expected to be subclassed, and the + * subclass is expected to implement language-specific functionality + * like writing documentation comments, tracking "imports", and adding + * any other kinds of helpful functionality for generating source code + * for a programming language. + * + *

The following example shows how a subclass of {@code CodegenWriter} + * should be created. CodegenWriters are expected to define a recursive + * type signature (notice that {@code MyWriter} is a generic parametric + * type in its own type definition). + * + *

{@code
+ * public final class MyWriter extends CodegenWriter {
+ *     public MyWriter(String namespace) {
+ *         super(new MyDocumentationWriter(), new MyImportContainer(namespace));
+ *     }
+ *
+ *     \@Override
+ *     public String toString() {
+ *         return getImportContainer().toString() + "\n\n" + super.toString();
+ *     }
+ *
+ *     public MyWriter someCustomMethod() {
+ *         // You can implement custom methods that are specific to whatever
+ *         // language you're implementing a generator for.
+ *         return this;
+ *     }
+ * }
+ * }
+ * + * @param The concrete type, used to provide a fluent interface. + * @param The import container used by the writer to manage imports. + */ +@SmithyUnstableApi +public class CodegenWriter, U extends ImportContainer> + extends CodeWriter implements SymbolDependencyContainer { + + private static final Logger LOGGER = Logger.getLogger(CodegenWriter.class.getName()); + + private final List dependencies = new ArrayList<>(); + private final DocumentationWriter documentationWriter; + private final U importContainer; + + /** + * @param documentationWriter Writes out documentation emitted by a {@code Runnable}. + * @param importContainer Container used to persist and filter imports based on package names. + */ + public CodegenWriter(DocumentationWriter documentationWriter, U importContainer) { + this.documentationWriter = documentationWriter; + this.importContainer = importContainer; + } + + /** + * Gets the import container associated with the writer. + * + *

The {@link #toString()} method of the {@code CodegenWriter} should + * be overridden so that it includes the import container's contents in + * the output as appropriate. + * + * @return Returns the import container. + */ + public final U getImportContainer() { + return importContainer; + } + + @Override + public final List getDependencies() { + return Collections.unmodifiableList(dependencies); + } + + /** + * Adds one or more dependencies to the generated code (represented as + * a {@link SymbolDependency}). + * + *

Tracking dependencies on a {@code CodegenWriter} allows dependencies + * to be automatically aggregated and collected in order to generate + * configuration files for dependency management tools (e.g., npm, + * maven, etc). + * + * @param dependencies Dependency to add. + * @return Returns the writer. + */ + @SuppressWarnings("unchecked") + public final T addDependency(SymbolDependencyContainer dependencies) { + List values = dependencies.getDependencies(); + LOGGER.finest(() -> String.format("Adding dependencies from %s: %s", dependencies, values)); + this.dependencies.addAll(values); + return (T) this; + } + + /** + * Imports one or more USE symbols using the name of the symbol + * (e.g., {@link SymbolReference.ContextOption#USE} references). + * + *

USE references are only necessary when referring to a symbol, not + * declaring the symbol. For example, when referring to a + * {@code List}, the USE references would be both the {@code List} + * type and {@code Foo} type. + * + *

This method may be overridden as needed. + * + * @param container Symbols to add. + * @return Returns the writer. + */ + @SuppressWarnings("unchecked") + public T addUseImports(SymbolContainer container) { + for (Symbol symbol : container.getSymbols()) { + addImport(symbol, symbol.getName(), SymbolReference.ContextOption.USE); + } + return (T) this; + } + + /** + * Imports a USE symbols possibly using an alias of the symbol + * (e.g., {@link SymbolReference.ContextOption#USE} references). + * + *

This method may be overridden as needed. + * + * @param symbolReference Symbol reference to import. + * @return Returns the writer. + * @see #addUseImports(SymbolContainer) + */ + public T addUseImports(SymbolReference symbolReference) { + return addImport(symbolReference.getSymbol(), symbolReference.getAlias(), SymbolReference.ContextOption.USE); + } + + /** + * Imports a symbol (if necessary) using a specific alias and list of + * context options. + * + *

This method automatically adds any dependencies of the {@code symbol} + * to the writer, calls {@link ImportContainer#importSymbol}, and + * automatically calls {@link #addImportReferences} for the provided + * {@code symbol}. + * + *

When called with no {@code options}, both {@code USE} and + * {@code DECLARE} symbols are imported from any references the + * {@code Symbol} might contain. + * + * @param symbol Symbol to optionally import. + * @param alias The alias to refer to the symbol by. + * @param options The list of context options (e.g., is it a USE or DECLARE symbol). + * @return Returns the writer. + */ + @SuppressWarnings("unchecked") + public final T addImport(Symbol symbol, String alias, SymbolReference.ContextOption... options) { + LOGGER.finest(() -> String.format("Adding import %s as `%s` (%s)", + symbol.getNamespace(), alias, Arrays.toString(options))); + + // Always add the dependencies of the symbol. + dependencies.addAll(symbol.getDependencies()); + + // Only add an import for the symbol if the symbol is external to the + // current "namespace" (where "namespace" can mean whatever is need to + // mean for each target language). + importContainer.importSymbol(symbol, alias); + + // Even if the symbol is in the same namespace as the current namespace, + // the symbol references of the given symbol always need to be imported + // because the assumption is that the symbol is being USED or DECLARED + // and is required ot refer to other symbols as part of the definition. + addImportReferences(symbol, options); + + return (T) this; + } + + /** + * Adds any imports to the writer by getting all of the references from the + * symbol that contain one or more of the given {@code options}. + * + * @param symbol Symbol to import the references of. + * @param options The options that must appear on the reference. + */ + final void addImportReferences(Symbol symbol, SymbolReference.ContextOption... options) { + for (SymbolReference reference : symbol.getReferences()) { + if (options.length == 0) { + addImport(reference.getSymbol(), reference.getAlias(), options); + } else { + for (SymbolReference.ContextOption option : options) { + if (reference.hasOption(option)) { + addImport(reference.getSymbol(), reference.getAlias(), options); + break; + } + } + } + } + } + + /** + * Writes documentation comments. + * + *

This method is responsible for setting up the writer to begin + * writing documentation comments. This includes writing any necessary + * opening tokens (e.g., "/*"), adding tokens to the beginning of lines + * (e.g., "*"), sanitizing documentation strings, and writing any + * tokens necessary to close documentation comments (e.g., "*\/"). + * + *

This method does not automatically escape the expression + * start character ("$" by default). Write calls made by the Runnable + * should either use {@link CodeWriter#writeWithNoFormatting} or escape + * the expression start character manually. + * + *

This method may be overridden as needed. + * + * @param runnable Runnable that handles actually writing docs with the writer. + * @return Returns the writer. + */ + @SuppressWarnings("unchecked") + public final T writeDocs(Runnable runnable) { + pushState(); + documentationWriter.writeDocs((T) this, runnable); + popState(); + return (T) this; + } + + /** + * Writes documentation comments from a string. + * + * @param docs Documentation to write. + * @return Returns the writer. + */ + @SuppressWarnings("unchecked") + public final T writeDocs(String docs) { + writeDocs(() -> writeWithNoFormatting(docs)); + return (T) this; + } +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegator.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegator.java new file mode 100644 index 00000000000..64f03ef904b --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegator.java @@ -0,0 +1,247 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Consumer; +import software.amazon.smithy.build.FileManifest; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolDependency; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.codegen.core.SymbolReference; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Creates and manages {@link CodegenWriter}s for files and namespaces based + * on {@link Symbol}s created for a {@link Shape}. + * + *

Overview

+ * + *

{@code CodegenWriterDelegator} is designed to generate code in files + * returned by the {@link Symbol#getDefinitionFile()} method of a {@link Symbol}. + * If multiple {@code Symbol}s are created that need to be defined in the same + * file, then the {@code CodegenWriterDelegator} ensures that the state of code + * generator associated with the file is persisted and only written when all + * shapes have been generated. + * + *

{@code CodegenWriter}s are lazily created each time a new filename is + * requested. If a {@code CodegenWriter} is already associated with a filename, + * a newline (\n) is written to the file before providing access to the + * {@code CodegenWriter}. All of the files and CodegenWriters stored in the + * delegator are eventually written to the provided {@link FileManifest} when + * the {@link #flushWriters()} method is called. + * + *

This class is not thread-safe. + * + *

Extending {@code CodegenWriterDelegator}

+ * + *

Language-specific code generators that utilize {@link Symbol} and + * {@link SymbolDependency} should extend both + * {@code CodegenWriterDelegator} and {@code CodegenWriter} to implement + * language specific functionality. Extending these classes also makes it + * easier to create new instances of them because they will be easier to work + * with since generic types aren't needed in concrete implementations. + * + * @param The type of {@link CodegenWriter} to create and manage. + */ +@SmithyUnstableApi +public class CodegenWriterDelegator> { + + private final FileManifest fileManifest; + private final SymbolProvider symbolProvider; + private final Map writers = new TreeMap<>(); + private final CodegenWriterFactory codegenWriterFactory; + private String automaticSeparator = "\n"; + private UseShapeWriterObserver useShaperWriterObserver = (shape, symbol, symbolProvider1, writer) -> { }; + + /** + * @param fileManifest Where code is written when {@link #flushWriters()} is called. + * @param symbolProvider Maps {@link Shape} to {@link Symbol} to determine the "namespace" and file of a shape. + * @param codegenWriterFactory Factory used to create new {@link CodegenWriter}s. + */ + public CodegenWriterDelegator( + FileManifest fileManifest, + SymbolProvider symbolProvider, + CodegenWriterFactory codegenWriterFactory + ) { + this.fileManifest = fileManifest; + this.symbolProvider = symbolProvider; + this.codegenWriterFactory = codegenWriterFactory; + } + + /** + * Gets all of the dependencies that have been registered in writers + * created by the {@code CodegenWriterDelegator}. + * + *

This method essentially just aggregates the results of calling + * {@link CodegenWriter#getDependencies()} of each created writer into + * a single array. + * + *

This method may be overridden as needed (for example, to add in + * some list of default dependencies or to inject other generative + * dependencies). + * + * @return Returns all the dependencies used in each {@code CodegenWriter}. + */ + public List getDependencies() { + List resolved = new ArrayList<>(); + writers.values().forEach(s -> resolved.addAll(s.getDependencies())); + return resolved; + } + + /** + * Writes each pending {@code CodegenWriter} to the {@link FileManifest}. + * + *

The {@code toString} method is called on each writer to generate + * the code to write to the manifest. + * + *

This method clears out the managed {@code CodegenWriter}s, meaning a + * subsequent call to {@link #getWriters()} will return an empty map. + * + *

This method may be overridden as needed. + */ + public void flushWriters() { + for (Map.Entry entry : getWriters().entrySet()) { + fileManifest.writeFile(entry.getKey(), entry.getValue().toString()); + } + + writers.clear(); + } + + /** + * Returns an immutable {@code Map} of created {@code CodegenWriter}s. + * + *

Each map key is the relative filename where the code will be written + * in the {@link FileManifest}, and each map value is the associated + * {@code CodegenWriter} of type {@code T}. + * + * @return Returns the immutable map of files to writers. + */ + public final Map getWriters() { + return Collections.unmodifiableMap(writers); + } + + /** + * Gets a previously created {@code CodegenWriter} or creates a new one + * if needed. + * + *

If a writer already exists, a newline is automatically appended to + * the writer (either a newline or whatever value was set on + * {@link #setAutomaticSeparator}). + * + * @param filename Name of the file to create. + * @param writerConsumer Consumer that is expected to write to the {@code CodegenWriter}. + */ + public final void useFileWriter(String filename, Consumer writerConsumer) { + useFileWriter(filename, "", writerConsumer); + } + + /** + * Gets a previously created writer or creates a new one if needed. + * + *

If a writer already exists, a newline is automatically appended to + * the writer (either a newline or whatever value was set on + * {@link #setAutomaticSeparator}). + * + * @param filename Name of the file to create. + * @param namespace Namespace associated with the file (or an empty string). + * @param writerConsumer Consumer that is expected to write to the {@code CodegenWriter}. + */ + public final void useFileWriter(String filename, String namespace, Consumer writerConsumer) { + writerConsumer.accept(checkoutWriter(filename, namespace)); + } + + /** + * Gets or creates a writer for a {@link Shape} by converting the {@link Shape} + * to a {@code Symbol}. + * + *

Any dependencies (i.e., {@link SymbolDependency}) required by the + * {@code Symbol} are automatically registered with the writer. + * + *

Any imports required to declare the {@code Symbol} in code (i.e., + * {@link SymbolReference.ContextOption#DECLARE}) are automatically + * registered with the writer. + * + *

If a writer already exists, a newline is automatically appended to + * the writer (either a newline or whatever value was set on + * {@link #setAutomaticSeparator}). + * + * @param shape Shape to create the writer for. + * @param writerConsumer Consumer that is expected to write to the {@code CodegenWriter}. + */ + public final void useShapeWriter(Shape shape, Consumer writerConsumer) { + // Checkout/create the appropriate writer for the shape. + Symbol symbol = symbolProvider.toSymbol(shape); + T writer = checkoutWriter(symbol.getDefinitionFile(), symbol.getNamespace()); + + // Add any needed DECLARE symbols. + writer.addImportReferences(symbol, SymbolReference.ContextOption.DECLARE); + symbol.getDependencies().forEach(writer::addDependency); + + writer.pushState(); + useShaperWriterObserver.observe(shape, symbol, symbolProvider, writer); + writerConsumer.accept(writer); + writer.popState(); + } + + /** + * Sets the automatic separator that is written to a {@code CodegenWriter} + * each time the writer is reused. + * + *

The default line separator is a newline ("\n"), but some + * implementations may wish to use an alternative value (e.g., "\r\n") or + * to disable the newline separator altogether by proving an empty string. + * + * @param automaticSeparator The non-null line separator to use. + */ + public final void setAutomaticSeparator(String automaticSeparator) { + this.automaticSeparator = Objects.requireNonNull(automaticSeparator); + } + + /** + * Sets the observer to invoke when shape writers are used. + * + *

The observer is invoked when a shape writer is used, allowing for + * customizations to be applied to the shape writer like invoking service + * providers to write default contents to generated code. + * + * @param useShaperWriterObserver Shape writer use observer. + */ + public void setOnShaperWriterUseObserver(UseShapeWriterObserver useShaperWriterObserver) { + this.useShaperWriterObserver = Objects.requireNonNull(useShaperWriterObserver); + } + + private T checkoutWriter(String filename, String namespace) { + String formattedFilename = Paths.get(filename).normalize().toString(); + boolean needsNewline = writers.containsKey(formattedFilename); + + T writer = writers.computeIfAbsent(formattedFilename, file -> codegenWriterFactory.apply(file, namespace)); + + // Add newlines/separators between types in the same file. + if (needsNewline) { + writer.writeInline(automaticSeparator); + } + + return writer; + } +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterFactory.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterFactory.java new file mode 100644 index 00000000000..ec341f024cc --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/CodegenWriterFactory.java @@ -0,0 +1,57 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.util.function.BiFunction; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Factory used to create a {@code CodegenWriter}. + * + *

The following example shows how to implement a basic + * {@code CodegenWriterFactory}. + * + *

{@code
+ * public final class MyWriterFactory implements CodegenWriterFactory {
+ *     \@Override
+ *     public MyWriter apply(String filename, String namespace) {
+ *         return new MyWriter(namespace);
+ *     }
+ * }
+ * }
+ * + *

Because {@code CodegenWriterFactory} is a {@link FunctionalInterface}, + * it can be implemented using a lambda expression: + * + *

{@code
+ * CodegenWriterFactory = (filename, namespace) -> new MyWriter(namespace);
+ * }
+ * + * @param Type of {@code CodegenWriter} to create. + */ +@FunctionalInterface +@SmithyUnstableApi +public interface CodegenWriterFactory> extends BiFunction { + /** + * Creates a {@code CodegenWriter} of type {@code T} for the given + * filename and namespace. + * + * @param filename Non-null filename of the writer being created. + * @param namespace Non-null namespace associated with the file (possibly empty string). + * @return Returns the created writer of type {@code T}. + */ + T apply(String filename, String namespace); +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/DocumentationWriter.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/DocumentationWriter.java new file mode 100644 index 00000000000..96673bc478b --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/DocumentationWriter.java @@ -0,0 +1,60 @@ +/* + * 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. + * 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.codegen.core.writer; + +import software.amazon.smithy.utils.CodeWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Responsible for properly writing documentation emitted when a + * {@code Runnable} in invoked. + * + *

The following example shows how to implement a basic + * {@code DocumentationWriter} that encloses documentation in + * successive lines that start with "///". + * + *

{@code
+ * public final class MyDocWriter implements DocumentationWriter {
+ *     \@Override
+ *     public void writeDocs(T writer, Runnable runnable) {
+ *         setNewlinePrefix("/// ")
+ *         runnable.run();
+ *     }
+ * }
+ * }
+ * + * @param The type of {@code CodegenWriter} being written to. + */ +@FunctionalInterface +@SmithyUnstableApi +public interface DocumentationWriter { + // Implementer's note: this class is not tied to CodegenWriter; it can be + // used with any kind of CodeWriter, allowing any kind of CodegenWriters to + // be used but also making this type more general-purpose. + + /** + * Writes documentation comments. + * + *

Implementations are expected to write out the beginning of a documentation + * comment, set any necessary prefix for each line written while writing docs, + * then invoke the given {@code runnable}, then finally write the closing + * characters for documentation. + * + * @param writer Writer to configure for writing documentation. + * @param runnable Runnable that handles actually writing docs with the writer. + */ + void writeDocs(T writer, Runnable runnable); +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/ImportContainer.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/ImportContainer.java new file mode 100644 index 00000000000..c3073d3ea47 --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/ImportContainer.java @@ -0,0 +1,57 @@ +/* + * 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. + * 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.codegen.core.writer; + +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.utils.CodeWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Contains the imports associated with a specific file. + * + *

The only required method is {@link #importSymbol}, but implementations + * are expected to also override {@link #toString()} so that it contains the + * formatted imports that can be written as code to a file. Other methods + * can, and should, be added to make working with language specific imports + * easier too. + */ +@SmithyUnstableApi +public interface ImportContainer { + /** + * Adds an import for the given symbol if and only if the "namespace" of the + * provided Symbol differs from the "namespace" associated with the + * ImportContainer. + * + *

"namespace" in this context can mean whatever it needs to mean for the + * target programming language. In some languages, it might mean the path to + * a file. In others, it might mean a proper namespace string. It's up to + * subclasses to both track a current "namespace" and implement this method + * in a way that makes sense. + * + * @param symbol Symbol to import if it's in another namespace. + * @param alias Alias to import the symbol as. + */ + void importSymbol(Symbol symbol, String alias); + + /** + * Implementations must implement a custom {@code toString} method that + * converts the collected imports to code that can be written to a + * {@link CodeWriter}. + * + * @return Returns the collected imports as a string. + */ + String toString(); +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilder.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilder.java new file mode 100644 index 00000000000..bf2aa1ad597 --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilder.java @@ -0,0 +1,144 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.util.function.Function; +import software.amazon.smithy.utils.CodeWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * A builder used to create a {@code DocumentationWriter} for Java style + * documentation comments. + * + *

Documentation comments are automatically sanitized by escaping a + * closing documentation comment (i.e., star (*) followed by a forward slash + * (/)). This should also work for JavaScript, PHP, and other languages that + * use Java-style comments. + */ +@SmithyUnstableApi +public final class JavaStyleDocumentationWriterBuilder { + + private String namedDocumentationSection; + private Function mappingFunction; + private boolean escapeAtSignWithEntity; + + /** + * A function used to escape the closing tokens of a documentation comment. + * + * @param contents Contents to sanitize. + * @return Returns the sanitized contents. + */ + public static String escapeClosingChars(String contents) { + return contents.replace("*/", "*\\/"); + } + + /** + * A function used to escape the {@literal @} sign of a documentation + * comment with an HTML entity of {@literal @}. + * + * @param contents Contents to sanitize. + * @return Returns the sanitized contents. + */ + public static String escapeAtSignWithEntity(String contents) { + return contents.replace("@", "@"); + } + + /** + * Creates a {@code DocumentationWriter} configured by the builder. + * + * @param The type of writer to create. + * @return Returns the created documentation writer. + */ + public DocumentationWriter build() { + Function function = resolveMappingFunction(); + String sectionName = namedDocumentationSection; + + return (writer, runnable) -> { + if (sectionName != null) { + writer.pushState(sectionName); + } + + writer.pushFilteredState(function); + writer.writeWithNoFormatting("/**"); + writer.setNewlinePrefix(" * "); + runnable.run(); + writer.popState(); + writer.writeWithNoFormatting(" */"); + + if (sectionName != null) { + writer.popState(); + } + }; + } + + private Function resolveMappingFunction() { + // Create a default mapping function that escapes closing comment + // tokens if one was not explicitly configured. + Function function = mappingFunction; + + if (mappingFunction == null) { + function = JavaStyleDocumentationWriterBuilder::escapeClosingChars; + } + + // Always compose at-sign escaping with whatever function was resolved. + if (escapeAtSignWithEntity) { + function = function.andThen(JavaStyleDocumentationWriterBuilder::escapeAtSignWithEntity); + } + + return function; + } + + /** + * Sets a specific named section to use when writing documentation. + * + * @param namedDocumentationSection The name of the state's section to use. + * @return Returns the builder. + */ + public JavaStyleDocumentationWriterBuilder namedDocumentationSection(String namedDocumentationSection) { + this.namedDocumentationSection = namedDocumentationSection; + return this; + } + + /** + * Sets a custom mapping function to use when filtering documentation. + * + *

Setting a custom mapping function will disable the default mapping + * function that is used to escape the closing tokens of a block comment. + * However, other mapping functions will still compose with a custom + * mapping function if provided (e.g., escaping {@literal @} symbols via + * {@link #escapeAtSignWithEntity(boolean)} still compose with a custom mapping function). + * + * @param mappingFunction Mapping function to use. Set to {@code null} to use the default. + * @return Returns the builder. + */ + public JavaStyleDocumentationWriterBuilder mappingFunction(Function mappingFunction) { + this.mappingFunction = mappingFunction; + return this; + } + + /** + * Sets whether or not the "@" sign is escaped with an HTML entity. + * + *

At signs are not escaped by default. + * + * @param escapeAtSignWithEntity Set to true to escape, false to not. + * @return Returns the builder. + */ + public JavaStyleDocumentationWriterBuilder escapeAtSignWithEntity(boolean escapeAtSignWithEntity) { + this.escapeAtSignWithEntity = escapeAtSignWithEntity; + return this; + } +} diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/UseShapeWriterObserver.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/UseShapeWriterObserver.java new file mode 100644 index 00000000000..6b157962844 --- /dev/null +++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/writer/UseShapeWriterObserver.java @@ -0,0 +1,68 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.util.function.Consumer; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.utils.CodeWriter; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * An observer invoked when a shape CodegenWriter is used. + * + *

The following example defines a {@code UseShapeWriterObserver} that + * writes a comment before a shape is written: + * + *

{@code
+ * public final class MyObserver implements UseShapeWriterObserver {
+ *     \@Override
+ *     public void observe(Shape shape, Symbol symbol, SymbolProvider symbolProvider, MyWriter writer) {
+ *         writer.write("/// Writing $L", shape.getId());
+ *     }
+ * }
+ * }
+ * + * @param Type of CodegenWriter being used. + */ +@FunctionalInterface +@SmithyUnstableApi +public interface UseShapeWriterObserver> { + /** + * Invoked when a {@link CodegenWriter} writer is used via + * {@link CodegenWriterDelegator#useShapeWriter(Shape, Consumer)}. + * + *

This is an extension point that allows code generators to perform + * any kind of preprocessing they need before code is written to a + * {@link CodegenWriter} for a given shape. For example, this could be + * used to add comments to the generated code to indicate that a file is + * auto-generated. + * + *

This method is invoked before the {@code writerConsumer} of + * {@link CodegenWriterDelegator#useShapeWriter} is called. This method + * is invoked within a pushed CodegenWriter state, so any state + * modifications made to the CodegenWriter will not persist after the the + * {@code writerConsumer} has completed (e.g., calls to methods like + * {@link CodeWriter#indent} are not persisted). + * + * @param shape Shape being used. + * @param symbol Symbol of the shape. + * @param symbolProvider SymbolProvider associated with the delegator. + * @param writer Writer being used for the shape. + */ + void observe(Shape shape, Symbol symbol, SymbolProvider symbolProvider, T writer); +} diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegatorTest.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegatorTest.java new file mode 100644 index 00000000000..fb66d693d14 --- /dev/null +++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterDelegatorTest.java @@ -0,0 +1,158 @@ +/* + * 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. + * 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.codegen.core.writer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.is; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.MockManifest; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolDependency; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.StringShape; + +public class CodegenWriterDelegatorTest { + + private static final class OnUse implements UseShapeWriterObserver { + private boolean onShapeWriterUseCalled; + + @Override + public void observe(Shape shape, Symbol symbol, SymbolProvider symbolProvider, MyWriter writer) { + onShapeWriterUseCalled = true; + } + } + + @Test + public void createsSymbolsAndFilesForShapeWriters() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> Symbol.builder() + .namespace("com.foo", ".") + .name("Baz") + .definitionFile("com/foo/Baz.bam") + .build(); + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + OnUse observer = new OnUse(); + delegator.setOnShaperWriterUseObserver(observer); + Shape shape = StringShape.builder().id("com.foo#Baz").build(); + delegator.useShapeWriter(shape, writer -> { }); + + assertThat(observer.onShapeWriterUseCalled, is(true)); + assertThat(delegator.getWriters(), hasKey("com/foo/Baz.bam")); + } + + @Test + public void canObserveAndWriteBeforeEachFile() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> Symbol.builder() + .namespace("com.foo", ".") + .name("Baz") + .definitionFile("com/foo/Baz.bam") + .build(); + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + MyWriter.MyObserver observer = new MyWriter.MyObserver(); + delegator.setOnShaperWriterUseObserver(observer); + Shape shape = StringShape.builder().id("com.foo#Baz").build(); + delegator.useShapeWriter(shape, writer -> { + writer.write("Hello"); + }); + + assertThat(delegator.getWriters().get("com/foo/Baz.bam").toString(), + equalTo("/// Writing com.foo#Baz\nHello\n")); + } + + @Test + public void aggregatesDependencies() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> null; + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + SymbolDependency dependency = SymbolDependency.builder() + .packageName("x") + .version("123") + .build(); + + delegator.useFileWriter("foo/baz", writer -> { + writer.addDependency(dependency); + }); + + assertThat(delegator.getDependencies(), contains(dependency)); + } + + @Test + public void writesNewlineBetweenFiles() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> null; + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + + delegator.useFileWriter("foo/baz", writer -> { + writer.write("."); + }); + + delegator.useFileWriter("foo/baz", writer -> { + writer.write("."); + }); + + assertThat(delegator.getWriters().get("foo/baz").toString(), equalTo(".\n\n.\n")); + } + + @Test + public void canDisableNewlineBetweenFiles() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> null; + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + delegator.setAutomaticSeparator(""); + + delegator.useFileWriter("foo/baz", writer -> { + writer.writeInline("."); + }); + + delegator.useFileWriter("foo/baz", writer -> { + writer.writeInline("."); + }); + + assertThat(delegator.getWriters().get("foo/baz").toString(), equalTo("..\n")); + } + + @Test + public void flushesAllWriters() { + MockManifest mockManifest = new MockManifest(); + SymbolProvider provider = (shape) -> Symbol.builder() + .namespace("com.foo", ".") + .name("Baz") + .definitionFile("com/foo/Baz.bam") + .build(); + CodegenWriterDelegator delegator = new CodegenWriterDelegator<>( + mockManifest, provider, (f, n) -> new MyWriter(n)); + Shape shape = StringShape.builder().id("com.foo#Baz").build(); + delegator.useShapeWriter(shape, writer -> { + writer.write("Hi!"); + }); + + delegator.flushWriters(); + + assertThat(mockManifest.getFileString("com/foo/Baz.bam"), equalTo(Optional.of("Hi!\n"))); + } +} diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterTest.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterTest.java new file mode 100644 index 00000000000..44bad123c3f --- /dev/null +++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/CodegenWriterTest.java @@ -0,0 +1,116 @@ +/* + * 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. + * 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.codegen.core.writer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.not; + +import org.junit.jupiter.api.Test; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolDependency; +import software.amazon.smithy.codegen.core.SymbolReference; + +public class CodegenWriterTest { + + @Test + public void managesDependencies() { + MyWriter writer = new MyWriter("foo"); + SymbolDependency dep = SymbolDependency.builder() + .packageName("foo") + .version("123") + .dependencyType("Dev") + .build(); + writer.addDependency(dep); + + assertThat(writer.getDependencies(), contains(dep)); + } + + @Test + public void writesDocumentationWithSanitation() { + MyWriter writer = new MyWriter("foo"); + writer.writeDocs("Hi $dollar!"); + String result = writer.toString(); + + assertThat(result, equalTo("Before\nHi $dollar!!\nAfter\n")); + } + + @Test + public void addsUseImportsWithReferences() { + MyWriter writer = new MyWriter("foo"); + Symbol s = Symbol.builder() + .declarationFile("foo.ts") + .definitionFile("foo.ts") + .name("Hello") + .namespace("com/foo", "/") + .build(); + SymbolReference reference = SymbolReference.builder() + .symbol(s) + .alias("X") + .options(SymbolReference.ContextOption.USE) + .build(); + writer.addUseImports(reference); + + assertThat(writer.getImportContainer().imports, hasKey("X")); + assertThat(writer.getImportContainer().imports.get("X"), equalTo("com/foo/Hello")); + } + + @Test + public void omitsUseImportsWithReferencesIfSameNamespace() { + MyWriter writer = new MyWriter("foo"); + Symbol s = Symbol.builder() + .declarationFile("foo.ts") + .definitionFile("foo.ts") + .name("Hello") + .namespace("foo", "/") + .build(); + SymbolReference reference = SymbolReference.builder() + .symbol(s) + .alias("X") + .options(SymbolReference.ContextOption.USE) + .build(); + writer.addUseImports(reference); + + assertThat(writer.getImportContainer().imports, not(hasKey("X"))); + } + + @Test + public void importsUseReferencesFromSymbols() { + MyWriter writer = new MyWriter("foo"); + Symbol string = Symbol.builder() + .definitionFile("java/lang/String.java") + .name("String") + .namespace("java.lang", ".") + .build(); + SymbolReference reference = SymbolReference.builder() + .symbol(string) + .alias("MyString") + .build(); + Symbol someList = Symbol.builder() + .definitionFile("java/util/List.java") + .name("List") + .namespace("java.util", ".") + .addReference(reference) + .build(); + writer.addUseImports(someList); + + assertThat(writer.getImportContainer().imports, hasKey("List")); + assertThat(writer.getImportContainer().imports, hasKey("MyString")); + assertThat(writer.getImportContainer().imports.get("MyString"), equalTo("java.lang.String")); + } +} diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilderTest.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilderTest.java new file mode 100644 index 00000000000..62822aad704 --- /dev/null +++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/JavaStyleDocumentationWriterBuilderTest.java @@ -0,0 +1,98 @@ +/* + * 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. + * 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.codegen.core.writer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import java.util.Locale; +import org.junit.jupiter.api.Test; + +public class JavaStyleDocumentationWriterBuilderTest { + @Test + public void writesAndEscapesBlockQuotes() { + MyWriter writer = new MyWriter("foo"); + DocumentationWriter docWriter = new JavaStyleDocumentationWriterBuilder().build(); + docWriter.writeDocs(writer, () -> { + writer.write("Hello."); + writer.write("*/"); + writer.write("Goodbye."); + }); + + assertThat(writer.toString(), + equalTo("/**\n * Hello.\n * *\\/\n * Goodbye.\n */\n")); + } + + @Test + public void canSetCustomSectionName() { + MyWriter writer = new MyWriter("foo"); + DocumentationWriter docWriter = new JavaStyleDocumentationWriterBuilder() + .namedDocumentationSection("docs") + .build(); + writer.onSection("docs", contents -> { + writer.writeWithNoFormatting(contents.toString().toUpperCase(Locale.ENGLISH)); + }); + docWriter.writeDocs(writer, () -> { + writer.write("Hello"); + }); + + assertThat(writer.toString(), + equalTo("/**\n * HELLO\n */\n")); + } + + @Test + public void canSetCustomMappingFunction() { + MyWriter writer = new MyWriter("foo"); + DocumentationWriter docWriter = new JavaStyleDocumentationWriterBuilder() + .mappingFunction(s -> s.toUpperCase(Locale.ENGLISH)) + .build(); + docWriter.writeDocs(writer, () -> { + writer.write("Hello"); + }); + + assertThat(writer.toString(), + equalTo("/**\n * HELLO\n */\n")); + } + + @Test + public void canEscapeAt() { + MyWriter writer = new MyWriter("foo"); + DocumentationWriter docWriter = new JavaStyleDocumentationWriterBuilder() + .escapeAtSignWithEntity(true) + .build(); + docWriter.writeDocs(writer, () -> { + writer.write("Hello @foo"); + }); + + assertThat(writer.toString(), + equalTo("/**\n * Hello @foo\n */\n")); + } + + @Test + public void canEscapeAtWithComposedCustomEscaper() { + MyWriter writer = new MyWriter("foo"); + DocumentationWriter docWriter = new JavaStyleDocumentationWriterBuilder() + .mappingFunction(s -> s.toUpperCase(Locale.ENGLISH)) + .escapeAtSignWithEntity(true) + .build(); + docWriter.writeDocs(writer, () -> { + writer.write("Hello @foo"); + }); + + assertThat(writer.toString(), + equalTo("/**\n * HELLO @FOO\n */\n")); + } +} diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/MyWriter.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/MyWriter.java new file mode 100644 index 00000000000..f30ab6a7473 --- /dev/null +++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/writer/MyWriter.java @@ -0,0 +1,70 @@ +/* + * 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. + * 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.codegen.core.writer; + +import java.util.Map; +import java.util.TreeMap; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.model.shapes.Shape; + +/** + * A pretty basic implementation of CodegenWriter. + */ +final class MyWriter extends CodegenWriter { + + public MyWriter(String namespace) { + super(new TestDocumentationWriter(), new MyImportContainer(namespace)); + } + + static final class MyImportContainer implements ImportContainer { + public final Map imports = new TreeMap<>(); + private final String namespace; + + private MyImportContainer(String namespace) { + this.namespace = namespace; + } + + @Override + public void importSymbol(Symbol symbol, String alias) { + if (!symbol.getNamespace().equals(namespace)) { + imports.put(alias, symbol.toString()); + } + } + } + + static final class TestDocumentationWriter implements DocumentationWriter { + @Override + public void writeDocs(MyWriter writer, Runnable runnable) { + writer.pushFilteredState(this::sanitizeDocString); + writer.write("Before"); + runnable.run(); + writer.write("After"); + writer.popState(); + } + + private String sanitizeDocString(String docs) { + return docs.replace("!", "!!"); + } + } + + static final class MyObserver implements UseShapeWriterObserver { + @Override + public void observe(Shape shape, Symbol symbol, SymbolProvider symbolProvider, MyWriter writer) { + writer.write("/// Writing $L", shape.getId()); + } + } +} 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 fb6475430ce..544837bd83b 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 @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; import java.util.regex.Pattern; /** @@ -472,6 +473,19 @@ public CodeWriter setExpressionStart(char expressionStart) { return this; } + /** + * Get the expression start character of the current state. + * + *

This value should not be cached and reused across pushed and popped + * states. This value is "$" by default, but it can be changed using + * {@link #setExpressionStart(char)}. + * + * @return Returns the expression start char of the current state. + */ + public char getExpressionStart() { + return currentState.expressionStart; + } + /** * Gets the contents of the generated code. * @@ -575,6 +589,20 @@ public CodeWriter pushState(String sectionName) { return this; } + /** + * Pushes an anonymous named state that is always passed through the given + * filter function before being written to the writer. + * + * @param filter Function that maps over the entire section when popped. + * @return Returns the code writer. + */ + public CodeWriter pushFilteredState(Function filter) { + String sectionName = "__filtered_state_" + states.size() + 1; + pushState(sectionName); + onSection(sectionName, content -> writeWithNoFormatting(filter.apply(content.toString()))); + return this; + } + /** * Pops the current CodeWriter state from the state stack. * @@ -1037,6 +1065,22 @@ public final CodeWriter closeBlock(String textAfterNewline, Object... args) { return dedent().write(textAfterNewline, args); } + /** + * Writes text to the CodeWriter and appends a newline. + * + *

The provided text does not use any kind of expression formatting. + * + *

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. + * @return Returns the CodeWriter. + */ + public final CodeWriter writeWithNoFormatting(Object content) { + currentState.writeLine(content.toString()); + return this; + } + /** * Writes text to the CodeWriter and appends a newline. * 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 e15f4ae4076..bd5379cf62f 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 @@ -18,6 +18,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import java.util.Locale; import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -670,4 +671,15 @@ public void expressionStartCannotBeSpace() { public void expressionStartCannotBeNewline() { Assertions.assertThrows(IllegalArgumentException.class, () -> new CodeWriter().setExpressionStart('\n')); } + + @Test + public void canFilterSections() { + CodeWriter writer = new CodeWriter(); + writer.pushFilteredState(s -> s.toUpperCase(Locale.ENGLISH)); + writer.write("Hello!"); + writer.write("Goodbye!"); + writer.popState(); + + assertThat(writer.toString(), equalTo("HELLO!\nGOODBYE!\n")); + } }