From 4232f2d732eb4bc88cbf5de841f82fef41e1acb1 Mon Sep 17 00:00:00 2001 From: David Gregory <2992938+DavidGregory084@users.noreply.github.com> Date: Wed, 29 May 2024 01:07:10 +0100 Subject: [PATCH] Add Doc parameters and overhaul rendering options --- .../com/opencastsoftware/prettier4j/Doc.java | 491 +++++++++++++++--- .../prettier4j/RenderOptions.java | 74 +++ .../opencastsoftware/prettier4j/DocTest.java | 320 +++++++++++- .../prettier4j/RenderOptionsTest.java | 21 + 4 files changed, 802 insertions(+), 104 deletions(-) create mode 100644 src/main/java/com/opencastsoftware/prettier4j/RenderOptions.java create mode 100644 src/test/java/com/opencastsoftware/prettier4j/RenderOptionsTest.java diff --git a/src/main/java/com/opencastsoftware/prettier4j/Doc.java b/src/main/java/com/opencastsoftware/prettier4j/Doc.java index 5d2261c..f223b63 100644 --- a/src/main/java/com/opencastsoftware/prettier4j/Doc.java +++ b/src/main/java/com/opencastsoftware/prettier4j/Doc.java @@ -39,12 +39,12 @@ * {@link Doc#styled(Doc, Styles.StylesOperator...)}. *

* To render documents to an {@link java.lang.Appendable Appendable} output, see the instance method - * {@link Doc#render(int, boolean, Appendable)} or static method - * {@link Doc#render(int, boolean, Doc, Appendable)}. + * {@link Doc#render(RenderOptions, Appendable)} or static method + * {@link Doc#render(Doc, RenderOptions, Appendable)}. *

* To render documents to {@link java.lang.String String}, see the instance method - * {@link Doc#render(int, boolean) render} or static method - * {@link Doc#render(int, boolean, Doc) render}. + * {@link Doc#render(RenderOptions)} or static method + * {@link Doc#render(Doc, RenderOptions)}. * * @see A @@ -59,6 +59,77 @@ public abstract class Doc { */ abstract Doc flatten(); + /** + * Indicate whether the current {@link com.opencastsoftware.prettier4j.Doc Doc} + * contains any parameters. + * + * @return whether this {@link Doc} contains any parameters. + */ + abstract boolean hasParams(); + + /** + * Bind a named parameter to the {@link Doc} provided via {@code value}. + * + * @param name the name of the parameter. + * @param value the value to use to replace the parameter placeholder. + * @return this {@link Doc} with all instances of the named parameter replaced by {@code value}. + */ + abstract Doc bind(String name, Doc value); + + /** + * Bind named parameters to the {@link Doc}s provided via {@code bindings}. + * + * @param bindings the bindings to use to replace named parameters. + * @return this {@link Doc} with all matching named parameters replaced by their corresponding values. + */ + abstract Doc bind(Map bindings); + + /** + * Bind named parameters to the name-to-{@link Doc} pairs provided via {@code bindings}. + * + * @param bindings the bindings to use to replace named parameters. + * @return this {@link Doc} with all matching named parameters replaced by their corresponding values. + */ + public Doc bind(Object... bindings) { + if (bindings.length % 2 != 0) { + throw new IllegalArgumentException( + "String-to-Doc pairs of arguments must be provided, but " + + bindings.length + " arguments were found."); + } + + Map bindingsMap = new HashMap<>(); + + for (int i = 0; i < bindings.length; i += 2) { + if (!(bindings[i] instanceof String)) { + throw new IllegalArgumentException( + "Key type must be String, but was " + + bindings[i].getClass().getSimpleName() + + " at index " + i + '.'); + } + if (!(bindings[i + 1] instanceof Doc)) { + throw new IllegalArgumentException( + "Value type must be Doc, but was " + + bindings[i + 1].getClass().getSimpleName() + + " at index" + (i + 1) + '.'); + } + + bindingsMap.put( + (String) bindings[i], + (Doc) bindings[i + 1]); + } + + return bind(bindingsMap); + } + + /** + * Bind a named parameter to the {@link String} provided via {@code value}. + * + * @return this {@link Doc} with all instances of the named parameter replaced by {@code value}. + */ + public Doc bind(String name, String value) { + return bind(name, Doc.text(value)); + } + /** * Append the {@code other} {@link com.opencastsoftware.prettier4j.Doc Doc} to * this one. @@ -247,16 +318,34 @@ public final Doc styled(Styles.StylesOperator...styles) { * Renders the current {@link com.opencastsoftware.prettier4j.Doc Doc} into a * {@link java.lang.String String}, aiming to lay out the document with at most * {@code width} characters on each line. - *

- * By default, ANSI escape codes are rendered to the output {@link java.lang.String String}. - *

- * To disable ANSI escape codes, see the {@link Doc#render(int, boolean)} overload of render. * - * @param width the preferred maximum rendering width. * @return the document laid out as a {@link java.lang.String String}. */ - public String render(int width) { - return render(width, this); + public String render() { + return render(this); + } + + /** + * Renders the current {@link com.opencastsoftware.prettier4j.Doc Doc} into a + * {@link java.lang.String String}, aiming to lay out the document with at most + * {@code width} characters on each line. + * + * @param options the options to use for rendering. + * @return the document laid out as a {@link java.lang.String String}. + */ + public String render(RenderOptions options) { + return render(this, options); + } + + /** + * Renders the current {@link com.opencastsoftware.prettier4j.Doc Doc} into a + * {@link java.lang.String String}, aiming to lay out the document with at most + * {@code width} characters on each line. + * + * @return the document laid out as a {@link java.lang.String String}. + */ + public void render(Appendable output) throws IOException { + render(this, output); } /** @@ -265,11 +354,10 @@ public String render(int width) { * {@code width} characters on each line. * * @param width the preferred maximum rendering width. - * @param ansi whether to render ANSI escape codes. * @return the document laid out as a {@link java.lang.String String}. */ - public String render(int width, boolean ansi) { - return render(width, ansi, this); + public String render(int width) { + return render(this, new RenderOptions(width, true)); } /** @@ -278,29 +366,24 @@ public String render(int width, boolean ansi) { * {@code width} characters on each line. * * @param width the preferred maximum rendering width. - * @param ansi whether to render ANSI escape codes. * @param output the output to render into. * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. */ - public void render(int width, boolean ansi, Appendable output) throws IOException { - render(width, ansi, this, output); + public void render(int width, Appendable output) throws IOException { + render(this, new RenderOptions(width, true), output); } /** * Renders the current {@link com.opencastsoftware.prettier4j.Doc Doc} into an * {@link java.lang.Appendable Appendable}, aiming to lay out the document with at most * {@code width} characters on each line. - *

- * By default, ANSI escape codes are rendered to the {@code output}. - *

- * To disable ANSI escape codes, see the {@link Doc#render(int, boolean, Appendable)} overload of render. * - * @param width the preferred maximum rendering width. + * @param options the options to use for rendering. * @param output the output to render into. * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. */ - public void render(int width, Appendable output) throws IOException { - render(width, this, output); + public void render(RenderOptions options, Appendable output) throws IOException { + render(this, options, output); } /** @@ -322,6 +405,21 @@ Doc flatten() { return this; } + @Override + boolean hasParams() { + return false; + } + + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public int hashCode() { final int prime = 31; @@ -379,6 +477,21 @@ Doc flatten() { return left.flatten().append(right.flatten()); } + @Override + boolean hasParams() { + return left.hasParams() || right.hasParams(); + } + + @Override + Doc bind(String name, Doc value) { + return new Append(left.bind(name, value), right.bind(name, value)); + } + + @Override + Doc bind(Map bindings) { + return new Append(left.bind(bindings), right.bind(bindings)); + } + @Override public int hashCode() { final int prime = 31; @@ -458,6 +571,21 @@ Doc flatten() { return left.flatten(); } + @Override + boolean hasParams() { + return left.hasParams() || right.hasParams(); + } + + @Override + Doc bind(String name, Doc value) { + return new Alternatives(left.bind(name, value), right.bind(name, value)); + } + + @Override + Doc bind(Map bindings) { + return new Alternatives(left.bind(bindings), right.bind(bindings)); + } + @Override public int hashCode() { final int prime = 31; @@ -520,6 +648,21 @@ Doc flatten() { return doc.flatten().indent(indent); } + @Override + boolean hasParams() { + return doc.hasParams(); + } + + @Override + Doc bind(String name, Doc value) { + return new Indent(indent, doc.bind(name, value)); + } + + @Override + Doc bind(Map bindings) { + return new Indent(indent, doc.bind(bindings)); + } + @Override public int hashCode() { final int prime = 31; @@ -569,6 +712,16 @@ public Line() { super(); } + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public String toString() { return "Line []"; @@ -587,6 +740,16 @@ public LineOrEmpty() { super(empty()); } + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public String toString() { return "LineOrEmpty []"; @@ -607,6 +770,16 @@ static LineOrSpace getInstance() { super(text(" ")); } + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public String toString() { return "LineOrSpace []"; @@ -630,7 +803,22 @@ protected LineOr(Doc altDoc) { @Override Doc flatten() { - return altDoc; + return altDoc != this ? altDoc.flatten() : altDoc; + } + + @Override + boolean hasParams() { + return altDoc != this && altDoc.hasParams(); + } + + @Override + Doc bind(String name, Doc value) { + return new LineOr(altDoc.bind(name, value)); + } + + @Override + Doc bind(Map bindings) { + return new LineOr(altDoc.bind(bindings)); } @Override @@ -696,6 +884,21 @@ Doc flatten() { return this; } + @Override + boolean hasParams() { + return false; + } + + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public String toString() { return "Empty []"; @@ -727,6 +930,21 @@ Doc flatten() { return new Styled(doc.flatten(), styles); } + @Override + boolean hasParams() { + return doc.hasParams(); + } + + @Override + Doc bind(String name, Doc value) { + return new Styled(doc.bind(name, value), styles); + } + + @Override + Doc bind(Map bindings) { + return new Styled(doc.bind(bindings), styles); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -768,6 +986,21 @@ Doc flatten() { return this; } + @Override + boolean hasParams() { + return false; + } + + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -790,7 +1023,7 @@ public String toString() { } /** - * Represents the end of a Doc that is {@link Doc#styled(Styles.StylesOperator...) styled} with an ANSI escape code sequence. + * Represents the end of a {@link Doc} that is {@link Doc#styled(Styles.StylesOperator...) styled} with an ANSI escape code sequence. */ public static class Reset extends Doc { private static final Reset INSTANCE = new Reset(); @@ -807,12 +1040,98 @@ Doc flatten() { return this; } + @Override + boolean hasParams() { + return false; + } + + @Override + Doc bind(String name, Doc value) { + return this; + } + + @Override + Doc bind(Map bindings) { + return this; + } + @Override public String toString() { return "Reset []"; } } + /** + * Represents a placeholder for a {@link Doc} that will be provided as a parameter. + */ + public static class Param extends Doc { + private final String name; + private final boolean flattened; + + private Param(String name, boolean flattened) { + this.name = name; + this.flattened = flattened; + } + + Param(String name) { + this(name, false); + } + + public String name() { + return this.name; + } + + @Override + Doc flatten() { + return new Param(name, true); + } + + @Override + boolean hasParams() { + return true; + } + + @Override + Doc bind(String name, Doc value) { + if (this.name.equals(name)) { + return flattened ? value.flatten() : value; + } else { + return this; + } + } + + @Override + Doc bind(Map bindings) { + Doc value = bindings.getOrDefault(this.name, this); + if (value != this) { + return flattened ? value.flatten() : value; + } else { + return this; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Param param = (Param) o; + return flattened == param.flattened && Objects.equals(name, param.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, flattened); + } + + @Override + public String toString() { + return "Param[" + + "name='" + name + '\'' + + ", flattened=" + flattened + + ']'; + } + } + /** * Construct a {@link com.opencastsoftware.prettier4j.Doc Doc} from the * {@code text}. @@ -931,6 +1250,16 @@ public static Doc styled(Doc doc, Styles.StylesOperator ...styles) { return new Styled(doc, styles); } + /** + * Creates a {@link Doc} which acts as a placeholder for a parameter {@link Doc} that will be provided + * by {@link Doc#bind(String, Doc) binding} parameters prior to {@link Doc#render(int) render}ing. + * @param name the name of the parameter. + * @return a parameter {@link Doc}. + */ + public static Doc param(String name) { + return new Param(name); + } + /** * Reduce a collection of documents using the binary operator {@code fn}, * returning an empty document if the collection is empty. @@ -1035,16 +1364,23 @@ static boolean fits(int remaining, Deque> entries) { * and line position, choosing the {@code left} document if it fits and the * {@code right} document otherwise. * - * @param width the preferred maximum width for rendering. - * @param indent the current indentation level. - * @param position the position in the current line. * @param left the preferred compact layout. * @param right the expanded layout. + * @param options the options to use for rendering. + * @param indent the current indentation level. + * @param position the position in the current line. * @return the entries of the chosen layout. */ - static Deque> chooseLayout(int width, boolean ansi, int indent, int position, Doc left, Doc right) { - Deque> leftEntries = normalize(width, ansi, indent, position, left); - return fits(width - position, leftEntries) ? leftEntries : normalize(width, ansi, indent, position, right); + static Deque> chooseLayout(Doc left, Doc right, RenderOptions options, int indent, int position) { + Deque> leftEntries = normalize(left, options, indent, position); + + int remaining = options.lineWidth() - position; + + if (fits(remaining, leftEntries)) { + return leftEntries; + } else { + return normalize(right, options, indent, position); + } } /** @@ -1053,14 +1389,13 @@ static Deque> chooseLayout(int width, boolean ansi, int * {@link com.opencastsoftware.prettier4j.Doc.LineOr LineOr}, and producing a * queue of entries to be rendered. * - * @param width the preferred maximum width for rendering. - * @param ansi whether to render ANSI escape codes. + * @param doc the document to be rendered. + * @param options the options to use for rendering. * @param indent the current indentation level. * @param position the current position in the line. - * @param doc the document to be rendered. * @return a queue of entries to be rendered. */ - static Deque> normalize(int width, boolean ansi, int indent, int position, Doc doc) { + static Deque> normalize(Doc doc, RenderOptions options, int indent, int position) { // Not yet normalized entries Deque> inQueue = new ArrayDeque<>(); @@ -1085,7 +1420,7 @@ static Deque> normalize(int width, boolean ansi, int ind } else if (entryDoc instanceof Styled) { // Eliminate Styled Styled styledDoc = (Styled) entryDoc; - if (ansi) { + if (options.emitAnsiEscapes()) { // Note reverse order inQueue.addFirst(new SimpleEntry<>(entryIndent, Reset.getInstance())); inQueue.addFirst(new SimpleEntry<>(entryIndent, styledDoc.doc())); @@ -1104,7 +1439,7 @@ static Deque> normalize(int width, boolean ansi, int ind Alternatives altDoc = (Alternatives) entryDoc; // These entries are already normalized Deque> chosenEntries = chooseLayout( - width, ansi, entryIndent, position, altDoc.left(), altDoc.right()); + altDoc.left(), altDoc.right(), options, entryIndent, position); // Note reverse order chosenEntries.descendingIterator().forEachRemaining(inQueue::addFirst); } else if (entryDoc instanceof Text) { @@ -1129,35 +1464,18 @@ static Deque> normalize(int width, boolean ansi, int ind /** * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into an - * {@link java.lang.Appendable Appendable}, aiming to lay out the document with at most - * {@code width} characters on each line. - *

- * By default, ANSI escape codes are rendered to the {@code output}. - *

- * To disable ANSI escape codes, see the {@link Doc#render(int, boolean, Doc, Appendable)} overload of render. + * {@link java.lang.Appendable Appendable}, attempting to lay out the document + * according to the rendering {@code options}. * - * @param width the preferred maximum rendering width. - * @param doc the document to be rendered. - * @param output the output to render into. + * @param doc the document to be rendered. + * @param options the options to use for rendering. + * @param output the output to render into. * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. */ - public static void render(int width, Doc doc, Appendable output) throws IOException { - render(width, true, doc, output); - } + public static void render(Doc doc, RenderOptions options, Appendable output) throws IOException { + if (doc.hasParams()) { throw new IllegalStateException("This Doc contains unbound parameters"); } - /** - * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into an - * {@link java.lang.Appendable Appendable}, aiming to lay out the document with at most - * {@code width} characters on each line. - * - * @param width the preferred maximum rendering width. - * @param ansi whether to render ANSI escape codes. - * @param doc the document to be rendered. - * @param output the output to render into. - * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. - */ - public static void render(int width, boolean ansi, Doc doc, Appendable output) throws IOException { - Deque> renderQueue = normalize(width, ansi, 0, 0, doc); + Deque> renderQueue = normalize(doc, options, 0, 0); AttrsStack attrsStack = new AttrsStack(); for (Map.Entry entry : renderQueue) { @@ -1171,7 +1489,7 @@ public static void render(int width, boolean ansi, Doc doc, Appendable output) t } else if (entryDoc instanceof LineOr) { output.append(System.lineSeparator()); for (int i = 0; i < entryIndent; i++) { - output.append(" "); + output.append(' '); } } else if (entryDoc instanceof Reset) { long resetAttrs = attrsStack.popLast(); @@ -1189,41 +1507,48 @@ public static void render(int width, boolean ansi, Doc doc, Appendable output) t } /** - * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into a - * {@link java.lang.String String}, aiming to lay out the document with at most - * {@code width} characters on each line. - *

- * By default, ANSI escape codes are rendered to the output {@link java.lang.String String}. - *

- * To disable ANSI escape codes, see the {@link Doc#render(int, boolean, Doc)} overload of render. + * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into an + * {@link java.lang.Appendable Appendable}, attempting to lay out the document + * according to the {@link RenderOptions#defaults() default} rendering options. * - * @param width the preferred maximum rendering width. - * @param doc the document to be rendered. - * @return the document laid out as a {@link java.lang.String String}. + * @param doc the document to be rendered. + * @param output the output to render into. + * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. */ - public static String render(int width, Doc doc) { - return render(width, true, doc); + public static void render(Doc doc, Appendable output) throws IOException { + render(doc, RenderOptions.defaults(), output); } /** * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into a - * {@link java.lang.String String}, aiming to lay out the document with at most - * {@code width} characters on each line. + * {@link java.lang.String String}, attempting to lay out the document + * according to the rendering {@code options}. * - * @param width the preferred maximum rendering width. - * @param ansi whether to render ANSI escape codes. - * @param doc the document to be rendered. + * @param doc the document to be rendered. + * @param options the options to use for rendering. * @return the document laid out as a {@link java.lang.String String}. */ - public static String render(int width, boolean ansi, Doc doc) { + public static String render(Doc doc, RenderOptions options) { StringBuilder output = new StringBuilder(); try { - render(width, ansi, doc, output); + render(doc, options, output); } catch (IOException ioe) { throw new UncheckedIOException(ioe); } return output.toString(); } + + /** + * Renders the input {@link com.opencastsoftware.prettier4j.Doc Doc} into a + * {@link java.lang.String String}, attempting to lay out the document + * according to the {@link RenderOptions#defaults() default} rendering options. + * + * @param doc the document to be rendered. + * @return the document laid out as a {@link java.lang.String String}. + */ + public static String render(Doc doc) { + return render(doc, RenderOptions.defaults()); + } } diff --git a/src/main/java/com/opencastsoftware/prettier4j/RenderOptions.java b/src/main/java/com/opencastsoftware/prettier4j/RenderOptions.java new file mode 100644 index 0000000..f777c82 --- /dev/null +++ b/src/main/java/com/opencastsoftware/prettier4j/RenderOptions.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: © 2024 Opencast Software Europe Ltd + * SPDX-License-Identifier: Apache-2.0 + */ +package com.opencastsoftware.prettier4j; + +import java.util.Objects; + +public class RenderOptions { + private final int lineWidth; + private final boolean emitAnsiEscapes; + + RenderOptions(int lineWidth, boolean emitAnsiEscapes) { + this.lineWidth = lineWidth; + this.emitAnsiEscapes = emitAnsiEscapes; + } + + public int lineWidth() { + return this.lineWidth; + } + + public boolean emitAnsiEscapes() { + return this.emitAnsiEscapes; + } + + public static RenderOptions defaults() { + return new RenderOptions(80, true); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RenderOptions that = (RenderOptions) o; + return lineWidth == that.lineWidth && emitAnsiEscapes == that.emitAnsiEscapes; + } + + @Override + public int hashCode() { + return Objects.hash(lineWidth, emitAnsiEscapes); + } + + @Override + public String toString() { + return "RenderOptions[" + + "lineWidth=" + lineWidth + + ", emitAnsiEscapes=" + emitAnsiEscapes + + ']'; + } + + public static class Builder { + private int lineWidth; + private boolean emitAnsiEscapes; + private Builder() {} + + public RenderOptions build() { + return new RenderOptions(this.lineWidth, this.emitAnsiEscapes); + } + + public Builder lineWidth(int width) { + this.lineWidth = width; + return this; + } + + public Builder emitAnsiEscapes(boolean emitAnsi) { + this.emitAnsiEscapes = emitAnsi; + return this; + } + } +} diff --git a/src/test/java/com/opencastsoftware/prettier4j/DocTest.java b/src/test/java/com/opencastsoftware/prettier4j/DocTest.java index aa89c00..8ae2a1b 100644 --- a/src/test/java/com/opencastsoftware/prettier4j/DocTest.java +++ b/src/test/java/com/opencastsoftware/prettier4j/DocTest.java @@ -18,11 +18,12 @@ import java.io.Writer; import java.util.Arrays; import java.util.Collections; +import java.util.function.UnaryOperator; import static com.opencastsoftware.prettier4j.Doc.*; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; public class DocTest { /** @@ -911,15 +912,29 @@ void testRenderToAppendable() throws IOException { assertThat(writer.toString(), is("a")); } + @Test + void testRenderToAppendableDefaultOptions() throws IOException { + Doc doc = text("a"); + Writer writer = new StringWriter(); + doc.render(writer); + assertThat(writer.toString(), is("a")); + } + @Test void testRenderAnsiDisabled() { + RenderOptions options = RenderOptions.builder() + .lineWidth(6) + .emitAnsiEscapes(false) + .build(); + String expected = "(a, b)"; + String actual = text("a").styled(Styles.blink()) .append(text(",")) .appendSpace(text("b").styled(Styles.blink())) .bracket(2, Doc.lineOrEmpty(), text("("), text(")")) .styled(Styles.faint()) - .render(6, false); + .render(options); char[] expectedChars = expected.toCharArray(); char[] actualChars = actual.toCharArray(); @@ -930,6 +945,12 @@ void testRenderAnsiDisabled() { @Test void testRenderToAppendableAnsiDisabled() throws IOException { Writer writer = new StringWriter(); + + RenderOptions options = RenderOptions.builder() + .lineWidth(6) + .emitAnsiEscapes(false) + .build(); + String expected = "(a, b)"; text("a").styled(Styles.blink()) @@ -937,7 +958,7 @@ void testRenderToAppendableAnsiDisabled() throws IOException { .appendSpace(text("b").styled(Styles.blink())) .bracket(2, Doc.lineOrEmpty(), text("("), text(")")) .styled(Styles.faint()) - .render(6, false, writer); + .render(options, writer); char[] expectedChars = expected.toCharArray(); char[] actualChars = writer.toString().toCharArray(); @@ -945,6 +966,76 @@ void testRenderToAppendableAnsiDisabled() throws IOException { assertThat(actualChars, is(equalTo(expectedChars))); } + @Test + void testRenderWithUnboundParam() { + Doc unbound = Doc.param("a"); + assertThrows(IllegalStateException.class, unbound::render); + } + + @Test + void testRenderWithParamBoundToItself() { + Doc unbound = Doc.param("a"); + Doc bound = unbound.bind("a", unbound); + assertThrows(IllegalStateException.class, bound::render); + } + + @Test + void testRenderWithMultipleParams() { + Doc unbound = param("a") + .append(text(",")) + .appendLineOrSpace(param("b")) + .bracket(2, lineOrEmpty(), text("("), text(")")); + + String rendered = unbound.bind( + "a", text("1"), + "b", text("2")) + .render(80); + + assertThat(rendered, is(equalTo("(1, 2)"))); + } + + @Test + void testRenderWithOddParams() { + Doc unbound = param("a") + .append(text(",")) + .appendLineOrSpace(param("b")) + .bracket(2, lineOrEmpty(), text("("), text(")")); + + assertThrows(IllegalArgumentException.class, () -> { + unbound.bind( + "a", text("1"), + "b"); + }); + } + + @Test + void testRenderWithIllTypedParamKey() { + Doc unbound = param("a") + .append(text(",")) + .appendLineOrSpace(param("b")) + .bracket(2, lineOrEmpty(), text("("), text(")")); + + assertThrows(IllegalArgumentException.class, () -> { + unbound.bind( + "a", text("1"), + 1, text("2")); + }); + } + + @Test + void testRenderWithIllTypedParamValue() { + Doc unbound = param("a") + .append(text(",")) + .appendLineOrSpace(param("b")) + .bracket(2, lineOrEmpty(), text("("), text(")")); + + assertThrows(IllegalArgumentException.class, () -> { + unbound.bind( + "a", text("1"), + "b", "2"); + }); + } + /** * This tests the {@code spread} operator of the original paper, implemented via * the Haskell functions: @@ -1016,8 +1107,8 @@ void groupTextEquivalentToIdentity( @Property void leftUnitLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("docs") Doc doc) { - String appended = doc.append(empty()).render(width); + @ForAll("noParamDocs") Doc doc) { + String appended = doc.append(Doc.empty()).render(width); String original = doc.render(width); assertThat(appended, is(equalTo(original))); } @@ -1033,8 +1124,8 @@ void leftUnitLaw( @Property void rightUnitLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("docs") Doc doc) { - String appended = empty().append(doc).render(width); + @ForAll("noParamDocs") Doc doc) { + String appended = Doc.empty().append(doc).render(width); String original = doc.render(width); assertThat(appended, is(equalTo(original))); } @@ -1050,9 +1141,9 @@ void rightUnitLaw( @Property void associativityLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("docs") Doc x, - @ForAll("docs") Doc y, - @ForAll("docs") Doc z) { + @ForAll("noParamDocs") Doc x, + @ForAll("noParamDocs") Doc y, + @ForAll("noParamDocs") Doc z) { String leftAssociated = x.append(y).append(z).render(width); String rightAssociated = x.append(y.append(z)).render(width); assertThat(leftAssociated, is(equalTo(rightAssociated))); @@ -1085,7 +1176,7 @@ void appendEquivalentToStringConcat( @Property void emptyEquivalentToEmptyText(@ForAll @IntRange(min = 5, max = 200) int width) { String emptyText = text("").render(width); - String emptyDoc = empty().render(width); + String emptyDoc = Doc.empty().render(width); assertThat(emptyText, is(equalTo(emptyDoc))); } @@ -1101,7 +1192,7 @@ void nestedIndentEquivalentToSumIndent( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll @IntRange(min = 0, max = 200) int i, @ForAll @IntRange(min = 0, max = 200) int j, - @ForAll("docs") Doc doc) { + @ForAll("noParamDocs") Doc doc) { String sumIndent = doc.indent(i + j).render(width); String nestedIndent = doc.indent(j).indent(i).render(width); assertThat(sumIndent, is(equalTo(nestedIndent))); @@ -1117,7 +1208,7 @@ void nestedIndentEquivalentToSumIndent( @Property void indentZeroEquivalentToNoIndent( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("docs") Doc doc) { + @ForAll("noParamDocs") Doc doc) { String zeroIndent = doc.indent(0).render(width); String noIndent = doc.render(width); assertThat(zeroIndent, is(equalTo(noIndent))); @@ -1134,8 +1225,8 @@ void indentZeroEquivalentToNoIndent( void indentDistributesOverAppend( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll @IntRange(min = 0, max = 200) int indent, - @ForAll("docs") Doc left, - @ForAll("docs") Doc right) { + @ForAll("noParamDocs") Doc left, + @ForAll("noParamDocs") Doc right) { String indentedAppend = left.append(right).indent(indent).render(width); String appendedIndents = left.indent(indent).append(right.indent(indent)).render(width); assertThat(indentedAppend, is(equalTo(appendedIndents))); @@ -1152,8 +1243,8 @@ void indentDistributesOverAppend( void emptyUnaffectedByIndent( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll @IntRange(min = 0, max = 200) int indent) { - String indentedEmpty = empty().indent(indent).render(width); - String noIndentEmpty = empty().render(width); + String indentedEmpty = Doc.empty().indent(indent).render(width); + String noIndentEmpty = Doc.empty().render(width); assertThat(indentedEmpty, is(equalTo(noIndentEmpty))); } @@ -1174,6 +1265,118 @@ void topLevelIndentEquivalentToNoIndent( assertThat(topLevelIndent, is(equalTo(noIndent))); } + @Property + void paramHasParams(@ForAll String paramName) { + Doc boundDoc = Doc.param(paramName); + assertThat(boundDoc.hasParams(), is(true)); + } + + @Property + void paramBindingEliminatesParam( + @ForAll String paramName, + @ForAll("noParamDocs") Doc argDoc + ) { + Doc boundDoc = Doc.param(paramName) + .bind(paramName, argDoc); + assertThat(boundDoc.hasParams(), is(false)); + } + + @Property + void paramBindingWrongNameDoesNothing( + @ForAll String paramName, + @ForAll String unrelatedName, + @ForAll("noParamDocs") Doc argDoc + ) { + Assume.that(!paramName.equals(unrelatedName)); + + Doc paramDoc = Doc.param(paramName); + + Doc boundDoc = paramDoc.bind(unrelatedName, argDoc); + + assertThat(boundDoc, is(sameInstance(paramDoc))); + + assertThat(boundDoc.hasParams(), is(true)); + } + + @Property + void paramBindingWrongNameMapDoesNothing( + @ForAll String paramName, + @ForAll String unrelatedName1, + @ForAll String unrelatedName2, + @ForAll("noParamDocs") Doc argDoc + ) { + Assume.that(!paramName.equals(unrelatedName1)); + Assume.that(!paramName.equals(unrelatedName2)); + Assume.that(!unrelatedName1.equals(unrelatedName2)); + + Doc paramDoc = Doc.param(paramName); + + Doc boundDoc = paramDoc.bind( + unrelatedName1, argDoc, + unrelatedName2, argDoc); + + assertThat(boundDoc, is(sameInstance(paramDoc))); + + assertThat(boundDoc.hasParams(), is(true)); + } + + @Property + void bindingTopLevelParamEquivalentToArgDoc( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll String paramName, + @ForAll("noParamDocs") Doc argDoc + ) { + String renderedArg = argDoc.render(width); + + String boundParam = Doc.param(paramName) + .bind(paramName, argDoc) + .render(width); + + assertThat(boundParam, is(equalTo(renderedArg))); + } + + @Property + void bindingTopLevelParamWithStringEquivalentToText( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll String paramName, + @ForAll String paramValue + ) { + String renderedText = Doc.text(paramValue).render(width); + + String boundParam = Doc.param(paramName) + .bind(paramName, paramValue) + .render(width); + + assertThat(boundParam, is(equalTo(renderedText))); + } + + @Property + void bindingDocWithoutParamsDoesNothing( + @ForAll("noParamDocs") Doc doc, + @ForAll String paramName, + @ForAll("noParamDocs") Doc argDoc + ) { + Doc boundDoc = doc.bind(paramName, argDoc); + assertThat(boundDoc, is(equalTo(doc))); + } + + @Property + void paramIsEquivalentToInlining( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll("unaryDocs") UnaryOperator unaryDoc, + @ForAll String paramName, + @ForAll("noParamDocs") Doc argDoc + ) { + String inlined = unaryDoc.apply(argDoc).render(width); + + String parameterized = unaryDoc + .apply(param(paramName)) + .bind(paramName, argDoc) + .render(width); + + assertThat(parameterized, is(equalTo(inlined))); + } + @Test void testEquals() { Doc left = docs().sample(); @@ -1188,7 +1391,7 @@ void testEquals() { // those prefab values must not be equal to each other EqualsVerifier .forClasses( - Text.class, Append.class, + Text.class, Append.class, Param.class, Alternatives.class, Indent.class, LineOr.class, Escape.class, Styled.class) .usingGetClass() @@ -1203,7 +1406,7 @@ void testToString() { Text.class, Append.class, Alternatives.class, Indent.class, LineOr.class, Empty.class, Escape.class, - Reset.class, Styled.class) + Reset.class, Styled.class, Param.class) .withPrefabValue(Doc.class, docs().sample()) .verify(); @@ -1213,6 +1416,74 @@ void testToString() { .verify(); } + @Provide + Arbitrary paramDocs() { + return docs().filter(Doc::hasParams); + } + + @Provide + Arbitrary noParamDocs() { + return docs().filter(doc -> !doc.hasParams()); + } + + @Provide + Arbitrary> unaryDocs() { + return Arbitraries.lazyOf( + () -> Arbitraries.of(Doc::group), + () -> Arbitraries.of(Doc::lineOr), + () -> Arbitraries.of(doc -> doc.indent(2)), + () -> Arbitraries.of(doc -> doc.bracket(2, lineOrEmpty(), text("["), text("]"))), + () -> noParamDocs().map(doc1 -> doc1::append), + () -> noParamDocs().map(doc1 -> doc2 -> doc2.append(doc1)), + () -> unaryDocs().tuple2().map(tuple -> doc -> tuple.get1().andThen(tuple.get2()).apply(doc)) + ); + } + + @Provide + Arbitrary styles() { + return Arbitraries.lazyOf( + () -> colors().map(Styles::fg), + () -> colors().map(Styles::bg), + () -> Arbitraries.of(Styles.bold()), + () -> Arbitraries.of(Styles.faint()), + () -> Arbitraries.of(Styles.italic()), + () -> Arbitraries.of(Styles.underline()), + () -> Arbitraries.of(Styles.blink()), + () -> Arbitraries.of(Styles.inverse()), + () -> Arbitraries.of(Styles.strikethrough()) + ); + } + + @Provide + Arbitrary colors() { + return Arbitraries.oneOf( + Arbitraries.create(Color::none), + Arbitraries.create(Color::black), + Arbitraries.create(Color::red), + Arbitraries.create(Color::green), + Arbitraries.create(Color::yellow), + Arbitraries.create(Color::blue), + Arbitraries.create(Color::magenta), + Arbitraries.create(Color::cyan), + Arbitraries.create(Color::white), + + Arbitraries.create(Color::brightBlack), + Arbitraries.create(Color::brightRed), + Arbitraries.create(Color::brightGreen), + Arbitraries.create(Color::brightYellow), + Arbitraries.create(Color::brightBlue), + Arbitraries.create(Color::brightMagenta), + Arbitraries.create(Color::brightCyan), + Arbitraries.create(Color::brightWhite), + + Arbitraries.integers().between(0, 255) + .map(Color::xterm), + + Arbitraries.integers().between(0, 255) + .tuple3().map(t -> Color.rgb(t.get1(), t.get2(), t.get3())) + ); + } + @Provide Arbitrary docs() { return Arbitraries.lazyOf( @@ -1236,7 +1507,14 @@ Arbitrary docs() { // Bracketing () -> docs().map(doc -> doc.bracket(2, Doc.lineOrEmpty(), Doc.text("["), Doc.text("]"))), // Alternatives - () -> docs().map(Doc::group)); + () -> docs().map(Doc::group), + // Param + () -> Arbitraries.strings().map(Doc::param), + // Styled + () -> styles().array(Styles.StylesOperator[].class).flatMap(styles -> { + return docs().map(doc -> doc.styled(styles)); + }) + ); } String sgrCode(int code) { diff --git a/src/test/java/com/opencastsoftware/prettier4j/RenderOptionsTest.java b/src/test/java/com/opencastsoftware/prettier4j/RenderOptionsTest.java new file mode 100644 index 0000000..3d70bbf --- /dev/null +++ b/src/test/java/com/opencastsoftware/prettier4j/RenderOptionsTest.java @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: © 2024 Opencast Software Europe Ltd + * SPDX-License-Identifier: Apache-2.0 + */ +package com.opencastsoftware.prettier4j; + +import com.jparams.verifier.tostring.ToStringVerifier; +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +public class RenderOptionsTest { + @Test + void testEquals() { + EqualsVerifier.forClass(RenderOptions.class).usingGetClass().verify(); + } + + @Test + void testToString() { + ToStringVerifier.forClass(RenderOptions.class).verify(); + } +}