diff --git a/src/main/java/com/opencastsoftware/prettier4j/Doc.java b/src/main/java/com/opencastsoftware/prettier4j/Doc.java index 206c067..5be4ab4 100644 --- a/src/main/java/com/opencastsoftware/prettier4j/Doc.java +++ b/src/main/java/com/opencastsoftware/prettier4j/Doc.java @@ -11,7 +11,6 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.util.*; -import java.util.AbstractMap.SimpleEntry; import java.util.function.BinaryOperator; import java.util.stream.Stream; @@ -63,6 +62,14 @@ private Doc() {} */ abstract boolean hasParams(); + /** + * Indicate whether the current {@link Doc} + * contains any line separators. + * + * @return whether this {@link Doc} contains any line separators. + */ + abstract boolean hasLineSeparators(); + /** * Bind a named parameter to the {@link Doc} provided via {@code value}. * @@ -88,9 +95,9 @@ private Doc() {} */ 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."); + throw new IllegalArgumentException( + "String-to-Doc pairs of arguments must be provided, but " + + bindings.length + " arguments were found."); } Map bindingsMap = new HashMap<>(); @@ -133,6 +140,8 @@ public Doc bind(String name, String value) { * @return the concatenated {@link Doc}. */ public Doc append(Doc other) { + // By left unit law + if (other instanceof Empty) { return this; } return new Append(this, other); } @@ -284,12 +293,48 @@ public Doc bracket(int indent, Doc lineDoc, String left, String right) { * @return the bracketed document. */ public Doc bracket(int indent, Doc lineDoc, Doc left, Doc right) { + return bracket(indent, lineDoc, empty(), left, right); + } + + /** + * Bracket the current document by the {@code left} and {@code right} documents, + * indented by {@code indent} spaces, applying the margin document {@code margin}. + *

+ * When collapsed, line separators are replaced by the {@code lineDoc}. + * + * @param indent the number of spaces of indent to apply. + * @param lineDoc the line separator document. + * @param marginDoc the margin document. + * @param left the left-hand bracket document. + * @param right the right-hand bracket document. + * @return the bracketed document. + */ + public Doc bracket(int indent, Doc lineDoc, Doc marginDoc, Doc left, Doc right) { return group( left - .append(lineDoc.append(this).indent(indent)) + .append(lineDoc.append(this).indent(indent).margin(marginDoc)) .append(lineDoc.append(right))); } + /** + * Apply the margin document {@code margin} to the current {@link Doc}, emitting the + * margin at the start of every new line from the start of this document until the + * end of the document. + *

+ * Note that line separators are forbidden inside the margin document. + *

+ * This is because each line separator causes the margin document to be produced. + *

+ * If the margin document in turn contained a line separator, rendering would never terminate. + * + * @param margin the margin document to apply at the start of every line. + * @return a document which prefixes every new line with the {@code margin} document. + * @throws IllegalArgumentException if the margin document contains line separators. + */ + public Doc margin(Doc margin) { + return margin(margin, this); + } + /** * Styles the current {@link Doc} using the styles provided via {@code styles}. * @@ -298,7 +343,7 @@ public Doc bracket(int indent, Doc lineDoc, Doc left, Doc right) { * @see Styles * @see com.opencastsoftware.prettier4j.ansi.Color Color */ - public final Doc styled(Styles.StylesOperator...styles) { + public final Doc styled(Styles.StylesOperator... styles) { return styled(this, styles); } @@ -349,7 +394,7 @@ public String render(int width) { * Renders the current {@link Doc} into an {@link Appendable}, attempting to lay out the document * with at most {@code width} characters on each line. * - * @param width the preferred maximum rendering width. + * @param width the preferred maximum rendering width. * @param output the output to render into. * @throws IOException if the {@link Appendable} {@code output} throws when {@link Appendable#append(CharSequence) append}ed. */ @@ -362,7 +407,7 @@ public void render(int width, Appendable output) throws IOException { * according to the rendering {@code options}. * * @param options the options to use for rendering. - * @param output the output to render into. + * @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(RenderOptions options, Appendable output) throws IOException { @@ -383,6 +428,17 @@ public String text() { return text; } + @Override + public Doc append(Doc other) { + // By string concat equivalency law + if (other instanceof Text) { + Text otherText = (Text) other; + return text(this.text() + otherText.text()); + } + + return new Append(this, other); + } + @Override Doc flatten() { return this; @@ -393,6 +449,11 @@ boolean hasParams() { return false; } + @Override + boolean hasLineSeparators() { + return false; + } + @Override public Doc bind(String name, Doc value) { return this; @@ -464,6 +525,11 @@ boolean hasParams() { return left.hasParams() || right.hasParams(); } + @Override + boolean hasLineSeparators() { + return left.hasLineSeparators() || right.hasLineSeparators(); + } + @Override public Doc bind(String name, Doc value) { return new Append(left.bind(name, value), right.bind(name, value)); @@ -562,6 +628,11 @@ boolean hasParams() { return left.hasParams() || right.hasParams(); } + @Override + boolean hasLineSeparators() { + return left.hasLineSeparators() || right.hasLineSeparators(); + } + @Override public Doc bind(String name, Doc value) { return new Alternatives(left.bind(name, value), right.bind(name, value)); @@ -639,6 +710,11 @@ boolean hasParams() { return doc.hasParams(); } + @Override + boolean hasLineSeparators() { + return doc.hasLineSeparators(); + } + @Override public Doc bind(String name, Doc value) { return new Indent(indent, doc.bind(name, value)); @@ -714,7 +790,9 @@ public String toString() { } } - /** Represents a line break which can be flattened into an empty document. */ + /** + * Represents a line break which can be flattened into an empty document. + */ public static class LineOrEmpty extends LineOr { private static final LineOrEmpty INSTANCE = new LineOrEmpty(); @@ -797,6 +875,11 @@ boolean hasParams() { return altDoc != this && altDoc.hasParams(); } + @Override + boolean hasLineSeparators() { + return true; + } + @Override public Doc bind(String name, Doc value) { return new LineOr(altDoc.bind(name, value)); @@ -852,6 +935,73 @@ public boolean equals(Object obj) { } } + /** + * Represents a {@link Doc} within which every new line is prefixed by a margin. + */ + public static class Margin extends Doc { + private final Doc margin; + private final Doc doc; + + protected Margin(Doc margin, Doc doc) { + this.margin = margin; + this.doc = doc; + } + + public Doc margin() { + return margin; + } + + public Doc doc() { + return doc; + } + + @Override + Doc flatten() { + return new Margin(margin, doc.flatten()); + } + + @Override + boolean hasParams() { + return margin.hasParams() || doc.hasParams(); + } + + @Override + boolean hasLineSeparators() { + return margin.hasLineSeparators() || doc.hasLineSeparators(); + } + + @Override + public Doc bind(String name, Doc value) { + return new Margin(margin.bind(name, value), doc.bind(name, value)); + } + + @Override + public Doc bind(Map bindings) { + return new Margin(margin.bind(bindings), doc.bind(bindings)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Margin margin1 = (Margin) o; + return Objects.equals(margin, margin1.margin) && Objects.equals(doc, margin1.doc); + } + + @Override + public int hashCode() { + return Objects.hash(margin, doc); + } + + @Override + public String toString() { + return "Margin[" + + "margin=" + margin + + ", doc=" + doc + + ']'; + } + } + /** * Represents an empty {@link Doc}. */ @@ -865,6 +1015,12 @@ static Empty getInstance() { Empty() { } + @Override + public Doc append(Doc other) { + // By right unit law + return other; + } + @Override Doc flatten() { return this; @@ -875,6 +1031,11 @@ boolean hasParams() { return false; } + @Override + boolean hasLineSeparators() { + return false; + } + @Override public Doc bind(String name, Doc value) { return this; @@ -921,6 +1082,11 @@ boolean hasParams() { return doc.hasParams(); } + @Override + boolean hasLineSeparators() { + return doc.hasLineSeparators(); + } + @Override public Doc bind(String name, Doc value) { return new Styled(doc.bind(name, value), styles); @@ -977,6 +1143,11 @@ boolean hasParams() { return false; } + @Override + boolean hasLineSeparators() { + return false; + } + @Override public Doc bind(String name, Doc value) { return this; @@ -1031,6 +1202,11 @@ boolean hasParams() { return false; } + @Override + boolean hasLineSeparators() { + return false; + } + @Override public Doc bind(String name, Doc value) { return this; @@ -1077,10 +1253,15 @@ boolean hasParams() { return true; } + @Override + boolean hasLineSeparators() { + return false; + } + @Override public Doc bind(String name, Doc value) { if (this.name.equals(name)) { - return flattened ? value.flatten() : value; + return flattened ? value.flatten() : value; } else { return this; } @@ -1125,6 +1306,8 @@ public String toString() { * @return a {@link Doc Doc} representing that {@link String}. */ public static Doc text(String text) { + // By empty text equivalency law + if (text.isEmpty()) { return empty(); } return new Text(text); } @@ -1147,9 +1330,44 @@ static Doc alternatives(Doc left, Doc right) { * @return the indented document. */ public static Doc indent(int indent, Doc doc) { + // By zero indent equivalency law + if (indent == 0) { return doc; } return new Indent(indent, doc); } + /** + * Apply the margin document {@code margin} to the current {@link Doc}, emitting the + * margin at the start of every new line from the start of this document until the + * end of the document. + *

+ * Note that line separators are forbidden inside the margin document. + *

+ * This is because each line separator causes the margin document to be produced. + *

+ * If the margin document in turn contained a line separator, rendering would never terminate. + * + * @param margin the margin document to apply at the start of every line. + * @param doc the input document. + * @return a document which prefixes every new line with the {@code margin} document. + * @throws IllegalArgumentException if the margin document contains line separators. + */ + public static Doc margin(Doc margin, Doc doc) { + // By empty margin equivalency law + if (margin instanceof Empty) { + return doc; + } + if (margin.hasLineSeparators()) { + throw new IllegalArgumentException("The margin document contains line separators."); + } + // By nested margin concat law + if (doc instanceof Margin) { + Margin marginDoc = (Margin) doc; + return new Margin(margin.append(marginDoc.margin()), marginDoc.doc()); + } else { + return new Margin(margin, doc); + } + } + /** * Creates a {@link Doc} representing a line break which cannot be flattened. * @@ -1192,7 +1410,7 @@ public static Doc empty() { * * @param altDoc the alternative document to use if the line break is flattened. * @return a {@link Doc} representing a line break which may be flattened into - an alternative document {@code altDoc}. + * an alternative document {@code altDoc}. */ public static Doc lineOr(Doc altDoc) { return new LineOr(altDoc); @@ -1204,7 +1422,7 @@ public static Doc lineOr(Doc altDoc) { * * @param altText the alternative text to use if the line break is flattened. * @return a {@link Doc} representing a line break which may be flattened into - * the alternative text {@code altText}. + * the alternative text {@code altText}. */ public static Doc lineOr(String altText) { return new LineOr(text(altText)); @@ -1213,19 +1431,22 @@ public static Doc lineOr(String altText) { /** * Styles the input {@link Doc} using the styles provided via {@code styles}. * - * @param doc the input document. + * @param doc the input document. * @param styles the styles to use to decorate the input {@code doc}. * @return a {@link Doc} decorated with the ANSI styles provided. * @see Styles * @see com.opencastsoftware.prettier4j.ansi.Color Color */ - public static Doc styled(Doc doc, Styles.StylesOperator ...styles) { + public static Doc styled(Doc doc, Styles.StylesOperator... styles) { + // By empty styles equivalency law + if (styles.length == 0) { return doc; } return new Styled(doc, styles); } /** * Creates a {@link Doc} which acts as a placeholder for an argument {@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}. */ @@ -1239,8 +1460,7 @@ public static Doc param(String name) { * * @param documents the collection of documents. * @param fn the binary operator for combining documents. - * @return a document built by reducing the {@code documents} using the operator - * {@code fn}. + * @return a document built by reducing the {@code documents} using the operator {@code fn}. */ public static Doc fold(Collection documents, BinaryOperator fn) { return fold(documents.stream(), fn); @@ -1252,8 +1472,7 @@ public static Doc fold(Collection documents, BinaryOperator fn) { * * @param documents the stream of documents. * @param fn the binary operator for combining documents. - * @return a document built by reducing the {@code documents} using the operator - * {@code fn}. + * @return a document built by reducing the {@code documents} using the operator {@code fn}. */ public static Doc fold(Stream documents, BinaryOperator fn) { return documents.reduce(fn).orElse(Doc.empty()); @@ -1266,7 +1485,7 @@ public static Doc fold(Stream documents, BinaryOperator fn) { * @param separator the separator document. * @param documents the collection of documents. * @return a document containing the concatenation of {@code documents} - * separated by the {@code separator}. + * separated by the {@code separator}. */ public static Doc intersperse(Doc separator, Collection documents) { return intersperse(separator, documents.stream()); @@ -1279,7 +1498,7 @@ public static Doc intersperse(Doc separator, Collection documents) { * @param separator the separator document. * @param documents the stream of documents. * @return a document containing the concatenation of {@code documents} - * separated by the {@code separator}. + * separated by the {@code separator}. */ public static Doc intersperse(Doc separator, Stream documents) { return Doc.fold(documents, (left, right) -> { @@ -1297,6 +1516,56 @@ public static Doc group(Doc doc) { return alternatives(doc.flatten(), doc); } + static final class Entry { + private final int indent; + private final Doc margin; + private final Doc doc; + + private Entry(int indent, Doc margin, Doc doc) { + this.indent = indent; + this.margin = margin; + this.doc = doc; + } + + int indent() { + return indent; + } + + Doc margin() { + return margin; + } + + Doc doc() { + return doc; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Entry entry = (Entry) o; + return indent == entry.indent && Objects.equals(margin, entry.margin) && Objects.equals(doc, entry.doc); + } + + @Override + public int hashCode() { + return Objects.hash(indent, margin, doc); + } + + @Override + public String toString() { + return "Entry[" + + "indent=" + indent + + ", margin=" + margin + + ", doc=" + doc + + ']'; + } + } + + static Entry entry(int indent, Doc margin, Doc doc) { + return new Entry(indent, margin, doc); + } + /** * Inspects the remaining space on the current line and the entries in the * current layout to see whether they fit onto the current line. @@ -1306,14 +1575,14 @@ public static Doc group(Doc doc) { * @param remaining the remaining space on the current line. * @param entries the entries we'd like to fit onto this line * @return true if we can fit all {@link Doc.Text entries} up to the - * next line break into the remaining characters of the current line. + * next line break into the remaining characters of the current line. */ - static boolean fits(int remaining, Deque> entries) { + static boolean fits(int remaining, Deque entries) { if (remaining < 0) return false; - for (Map.Entry entry : entries) { - Doc entryDoc = entry.getValue(); + for (Entry entry : entries) { + Doc entryDoc = entry.doc(); // normalization reduces Doc to Text, LineOr, Escape and Reset if (entryDoc instanceof Text) { @@ -1323,7 +1592,7 @@ static boolean fits(int remaining, Deque> entries) { return false; } else if (entryDoc instanceof LineOr) { return true; - } // No need to handle Escape or Reset here + } // No need to handle Escape or Reset as they are effectively zero-length } return true; @@ -1337,80 +1606,89 @@ static boolean fits(int remaining, Deque> entries) { * @param left the preferred compact layout. * @param right the expanded layout. * @param options the options to use for rendering. + * @param margin the current margin. * @param indent the current indentation level. * @param position the position in the current line. * @return the entries of the chosen layout. */ - static Deque> chooseLayout(Doc left, Doc right, RenderOptions options, int indent, int position) { - Deque> leftEntries = normalize(left, options, indent, position); + static Deque chooseLayout(Doc left, Doc right, RenderOptions options, Doc margin, int indent, int position) { + Deque leftEntries = normalize(left, options, margin, indent, position); int remaining = options.lineWidth() - position; if (fits(remaining, leftEntries)) { return leftEntries; } else { - return normalize(right, options, indent, position); + return normalize(right, options, margin, indent, position); } } /** - * Traverse the input {@code doc} recursively, eliminating all nodes except for - * {@link Doc.Text Text} and subtypes of {@link Doc.LineOr LineOr}, and producing a - * queue of entries to be rendered. + * Traverses the input {@code doc} recursively, eliminating all nodes except for + * {@link Text}, {@link Escape}, {@link Reset} and subtypes of {@link LineOr}, + * and produces a queue of entries to be rendered. * * @param doc the document to be rendered. * @param options the options to use for rendering. + * @param margin the current margin. * @param indent the current indentation level. * @param position the current position in the line. * @return a queue of entries to be rendered. */ - static Deque> normalize(Doc doc, RenderOptions options, int indent, int position) { + static Deque normalize(Doc doc, RenderOptions options, Doc margin, int indent, int position) { // Not yet normalized entries - Deque> inQueue = new ArrayDeque<>(); + Deque inQueue = new ArrayDeque<>(); // Normalized entries - Deque> outQueue = new ArrayDeque<>(); + Deque outQueue = new ArrayDeque<>(); // Start with the outer Doc - inQueue.add(new SimpleEntry<>(indent, doc)); + inQueue.add(entry(indent, margin, doc)); while (!inQueue.isEmpty()) { - Map.Entry topEntry = inQueue.removeFirst(); + Entry topEntry = inQueue.removeFirst(); - int entryIndent = topEntry.getKey(); - Doc entryDoc = topEntry.getValue(); + int entryIndent = topEntry.indent(); + Doc entryMargin = topEntry.margin(); + Doc entryDoc = topEntry.doc(); if (entryDoc instanceof Append) { // Eliminate Append Append appendDoc = (Append) entryDoc; // Note reverse order - inQueue.addFirst(new SimpleEntry<>(entryIndent, appendDoc.right())); - inQueue.addFirst(new SimpleEntry<>(entryIndent, appendDoc.left())); + inQueue.addFirst(entry(entryIndent, entryMargin, appendDoc.right())); + inQueue.addFirst(entry(entryIndent, entryMargin, appendDoc.left())); } else if (entryDoc instanceof Styled) { // Eliminate Styled Styled styledDoc = (Styled) entryDoc; if (options.emitAnsiEscapes()) { // Note reverse order - inQueue.addFirst(new SimpleEntry<>(entryIndent, Reset.getInstance())); - inQueue.addFirst(new SimpleEntry<>(entryIndent, styledDoc.doc())); - inQueue.addFirst(new SimpleEntry<>(entryIndent, new Escape(styledDoc.styles()))); + inQueue.addFirst(entry(entryIndent, entryMargin, Reset.getInstance())); + inQueue.addFirst(entry(entryIndent, entryMargin, styledDoc.doc())); + inQueue.addFirst(entry(entryIndent, entryMargin, new Escape(styledDoc.styles()))); } else { // Ignore styles and emit the underlying Doc - inQueue.addFirst(new SimpleEntry<>(entryIndent, styledDoc.doc())); + inQueue.addFirst(entry(entryIndent, entryMargin, styledDoc.doc())); } } else if (entryDoc instanceof Indent) { // Eliminate Indent Indent indentDoc = (Indent) entryDoc; int newIndent = entryIndent + indentDoc.indent(); - inQueue.addFirst(new SimpleEntry<>(newIndent, indentDoc.doc())); + inQueue.addFirst(entry(newIndent, entryMargin, indentDoc.doc())); + } else if (entryDoc instanceof Margin) { + // Eliminate Margin + Margin marginDoc = (Margin) entryDoc; + // Note reverse order + Doc newMargin = entryMargin.append(marginDoc.margin()); + inQueue.addFirst(entry(entryIndent, newMargin, marginDoc.doc())); } else if (entryDoc instanceof Alternatives) { // Eliminate Alternatives Alternatives altDoc = (Alternatives) entryDoc; // These entries are already normalized - Deque> chosenEntries = chooseLayout( - altDoc.left(), altDoc.right(), options, entryIndent, position); - // Note reverse order - chosenEntries.descendingIterator().forEachRemaining(inQueue::addFirst); + Deque chosenEntries = chooseLayout( + altDoc.left(), altDoc.right(), + options, entryMargin, entryIndent, position); + chosenEntries.forEach(outQueue::addLast); } else if (entryDoc instanceof Text) { Text textDoc = (Text) entryDoc; // Keep track of line length @@ -1419,6 +1697,16 @@ static Deque> normalize(Doc doc, RenderOptions options, } else if (entryDoc instanceof LineOr) { // Reset line length position = entryIndent; + // Note reverse order + if (entryIndent > 0) { + // Send out the indent spaces + char[] indentChars = new char[entryIndent]; + Arrays.fill(indentChars, ' '); + String indentSpaces = new String(indentChars); + inQueue.addFirst(entry(entryIndent, entryMargin, text(indentSpaces))); + } + // Send out the current margin + inQueue.addFirst(entry(entryIndent, entryMargin, entryMargin)); outQueue.addLast(topEntry); } else if (entryDoc instanceof Escape) { outQueue.addLast(topEntry); @@ -1443,12 +1731,11 @@ static Deque> normalize(Doc doc, RenderOptions options, public static void render(Doc doc, RenderOptions options, Appendable output) throws IOException { if (doc.hasParams()) { throw new IllegalStateException("This Doc contains unbound parameters"); } - Deque> renderQueue = normalize(doc, options, 0, 0); + Deque renderQueue = normalize(doc, options, empty(), 0, 0); AttrsStack attrsStack = new AttrsStack(); - for (Map.Entry entry : renderQueue) { - int entryIndent = entry.getKey(); - Doc entryDoc = entry.getValue(); + for (Entry entry : renderQueue) { + Doc entryDoc = entry.doc(); // normalization reduces Doc to Text, LineOr, Escape and Reset if (entryDoc instanceof Text) { @@ -1456,9 +1743,6 @@ public static void render(Doc doc, RenderOptions options, Appendable output) thr output.append(textDoc.text()); } else if (entryDoc instanceof LineOr) { output.append(System.lineSeparator()); - for (int i = 0; i < entryIndent; i++) { - output.append(' '); - } } else if (entryDoc instanceof Reset) { long resetAttrs = attrsStack.popLast(); long prevAttrs = attrsStack.peekLast(); @@ -1478,8 +1762,8 @@ public static void render(Doc doc, RenderOptions options, Appendable output) thr * Renders the input {@link Doc} into an {@link Appendable}, attempting to lay out the document * according to the {@link RenderOptions#defaults() default} rendering options. * - * @param doc the document to be rendered. - * @param output the output to render into. + * @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(Doc doc, Appendable output) throws IOException { diff --git a/src/main/java/com/opencastsoftware/prettier4j/ansi/Attrs.java b/src/main/java/com/opencastsoftware/prettier4j/ansi/Attrs.java index da6bea9..3c49baf 100644 --- a/src/main/java/com/opencastsoftware/prettier4j/ansi/Attrs.java +++ b/src/main/java/com/opencastsoftware/prettier4j/ansi/Attrs.java @@ -59,7 +59,7 @@ public static long withStyles(long attrs, Styles.StylesOperator ...styles) { } public static String transition(long prev, long next) { - if (next == NULL) { + if (next <= EMPTY) { return isEmpty(prev) ? "" : AnsiConstants.RESET; } diff --git a/src/test/java/com/opencastsoftware/prettier4j/DocTest.java b/src/test/java/com/opencastsoftware/prettier4j/DocTest.java index 8ae2a1b..8b1c39a 100644 --- a/src/test/java/com/opencastsoftware/prettier4j/DocTest.java +++ b/src/test/java/com/opencastsoftware/prettier4j/DocTest.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.function.UnaryOperator; +import java.util.stream.Collectors; import static com.opencastsoftware.prettier4j.Doc.*; import static org.hamcrest.MatcherAssert.assertThat; @@ -211,6 +212,47 @@ void testBracketFlattening() { assertThat(actual, is(equalTo(expected))); } + @Test + void testMarginWithLineSeparator() { + assertThrows(IllegalArgumentException.class, () -> { + text("bla").margin(lineOrSpace()); + }); + } + + @Test + void testMargin() { + String expected = "\n|functionCall(\n| a,\n| b,\n| c\n|)"; + + // Margin only applies after the first line break, so we must begin with a line() + String actual = line().append(text("functionCall")) + .append( + Doc.intersperse( + Doc.text(",").append(Doc.lineOrSpace()), + Arrays.asList("a", "b", "c").stream().map(Doc::text)) + .bracket(2, Doc.lineOrEmpty(), Doc.text("("), Doc.text(")"))) + .margin(text("|")) + .render(10); + + assertThat(actual, is(equalTo(expected))); + } + + @Test + void testNestedMargin() { + String expected = "\n|functionCall(\n|> a,\n|> b,\n|> c\n|)"; + + // Margin only applies after the first line break, so we must begin with a line() + String actual = line().append(text("functionCall")) + .append( + Doc.intersperse( + Doc.text(",").append(Doc.lineOrSpace()), + Arrays.asList("a", "b", "c").stream().map(Doc::text)) + .bracket(2, Doc.lineOrEmpty(), text(">"), Doc.text("("), Doc.text(")"))) + .margin(text("|")) + .render(10); + + assertThat(actual, is(equalTo(expected))); + } + @Test void testIntersperse() { String expected = "a, b, c"; @@ -450,7 +492,7 @@ void testNullFgStyle() { @Test void testNestedNullFgStyle() { String expected = - sgrCode(37) + '(' + + sgrCode(3, 37) + '(' + sgrCode(39) + 'a' + sgrCode(37) + ", " + sgrCode(39) + 'b' + @@ -460,7 +502,7 @@ void testNestedNullFgStyle() { .append(text(",")) .appendSpace(text("b").styled(Styles.fg(null))) .bracket(2, Doc.lineOrEmpty(), text("("), text(")")) - .styled(Styles.fg(Color.white())) + .styled(Styles.fg(Color.white()), Styles.italic()) .render(6); char[] expectedChars = expected.toCharArray(); @@ -683,7 +725,7 @@ void testNullBgStyle() { @Test void testNestedNullBgStyle() { String expected = - sgrCode(47) + '(' + + sgrCode(1, 47) + '(' + sgrCode(49) + 'a' + sgrCode(47) + ", " + sgrCode(49) + 'b' + @@ -693,7 +735,7 @@ void testNestedNullBgStyle() { .append(text(",")) .appendSpace(text("b").styled(Styles.bg(null))) .bracket(2, Doc.lineOrEmpty(), text("("), text(")")) - .styled(Styles.bg(Color.white())) + .styled(Styles.bg(Color.white()), Styles.bold()) .render(6); char[] expectedChars = expected.toCharArray(); @@ -1107,7 +1149,7 @@ void groupTextEquivalentToIdentity( @Property void leftUnitLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("noParamDocs") Doc doc) { + @ForAll("docsWithSeparators") Doc doc) { String appended = doc.append(Doc.empty()).render(width); String original = doc.render(width); assertThat(appended, is(equalTo(original))); @@ -1124,7 +1166,7 @@ void leftUnitLaw( @Property void rightUnitLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("noParamDocs") Doc doc) { + @ForAll("docsWithSeparators") Doc doc) { String appended = Doc.empty().append(doc).render(width); String original = doc.render(width); assertThat(appended, is(equalTo(original))); @@ -1141,9 +1183,9 @@ void rightUnitLaw( @Property void associativityLaw( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("noParamDocs") Doc x, - @ForAll("noParamDocs") Doc y, - @ForAll("noParamDocs") Doc z) { + @ForAll("docsWithSeparators") Doc x, + @ForAll("docsWithSeparators") Doc y, + @ForAll("docsWithSeparators") 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))); @@ -1192,7 +1234,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("noParamDocs") Doc doc) { + @ForAll("docsWithSeparators") Doc doc) { String sumIndent = doc.indent(i + j).render(width); String nestedIndent = doc.indent(j).indent(i).render(width); assertThat(sumIndent, is(equalTo(nestedIndent))); @@ -1208,7 +1250,7 @@ void nestedIndentEquivalentToSumIndent( @Property void indentZeroEquivalentToNoIndent( @ForAll @IntRange(min = 5, max = 200) int width, - @ForAll("noParamDocs") Doc doc) { + @ForAll("docsWithSeparators") Doc doc) { String zeroIndent = doc.indent(0).render(width); String noIndent = doc.render(width); assertThat(zeroIndent, is(equalTo(noIndent))); @@ -1225,8 +1267,8 @@ void indentZeroEquivalentToNoIndent( void indentDistributesOverAppend( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll @IntRange(min = 0, max = 200) int indent, - @ForAll("noParamDocs") Doc left, - @ForAll("noParamDocs") Doc right) { + @ForAll("docsWithSeparators") Doc left, + @ForAll("docsWithSeparators") 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))); @@ -1265,6 +1307,35 @@ void topLevelIndentEquivalentToNoIndent( assertThat(topLevelIndent, is(equalTo(noIndent))); } + @Property + void emptyMarginEquivalentToNoMargin( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll("docsWithSeparators") Doc doc) { + String noMargin = doc.render(width); + String emptyMargin = doc.margin(Doc.empty()).render(width); + assertThat(emptyMargin, is(equalTo(noMargin))); + } + + @Property + void nestedMarginEquivalentToAppendMargin( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll("docsWithSeparators") Doc doc, + @ForAll("docs") Doc margin1, + @ForAll("docs") Doc margin2) { + String appendMargin = doc.margin(margin1.append(margin2)).render(width); + String nestedMargin = doc.margin(margin2).margin(margin1).render(width); + assertThat(nestedMargin, is(equalTo(appendMargin))); + } + + @Property + void emptyStylesEquivalentToNoStyles( + @ForAll @IntRange(min = 5, max = 200) int width, + @ForAll("docsWithSeparators") Doc doc) { + String emptyStyles = doc.styled().render(width); + String noStyles = doc.render(width); + assertThat(emptyStyles.toCharArray(), is(equalTo(noStyles.toCharArray()))); + } + @Property void paramHasParams(@ForAll String paramName) { Doc boundDoc = Doc.param(paramName); @@ -1274,7 +1345,7 @@ void paramHasParams(@ForAll String paramName) { @Property void paramBindingEliminatesParam( @ForAll String paramName, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { Doc boundDoc = Doc.param(paramName) .bind(paramName, argDoc); @@ -1285,7 +1356,7 @@ void paramBindingEliminatesParam( void paramBindingWrongNameDoesNothing( @ForAll String paramName, @ForAll String unrelatedName, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { Assume.that(!paramName.equals(unrelatedName)); @@ -1303,7 +1374,7 @@ void paramBindingWrongNameMapDoesNothing( @ForAll String paramName, @ForAll String unrelatedName1, @ForAll String unrelatedName2, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { Assume.that(!paramName.equals(unrelatedName1)); Assume.that(!paramName.equals(unrelatedName2)); @@ -1324,7 +1395,7 @@ void paramBindingWrongNameMapDoesNothing( void bindingTopLevelParamEquivalentToArgDoc( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll String paramName, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { String renderedArg = argDoc.render(width); @@ -1352,9 +1423,9 @@ void bindingTopLevelParamWithStringEquivalentToText( @Property void bindingDocWithoutParamsDoesNothing( - @ForAll("noParamDocs") Doc doc, + @ForAll("docsWithSeparators") Doc doc, @ForAll String paramName, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { Doc boundDoc = doc.bind(paramName, argDoc); assertThat(boundDoc, is(equalTo(doc))); @@ -1365,7 +1436,7 @@ void paramIsEquivalentToInlining( @ForAll @IntRange(min = 5, max = 200) int width, @ForAll("unaryDocs") UnaryOperator unaryDoc, @ForAll String paramName, - @ForAll("noParamDocs") Doc argDoc + @ForAll("docsWithSeparators") Doc argDoc ) { String inlined = unaryDoc.apply(argDoc).render(width); @@ -1377,11 +1448,21 @@ void paramIsEquivalentToInlining( assertThat(parameterized, is(equalTo(inlined))); } + @Test + void testEntryEquals() { + EqualsVerifier.forClass(Entry.class).usingGetClass().verify(); + } + + @Test + void testEntryToString() { + ToStringVerifier.forClass(Entry.class).verify(); + } + @Test void testEquals() { - Doc left = docs().sample(); + Doc left = docsWithParams().sample(); - Doc right = docs().sampleStream() + Doc right = docsWithParams().sampleStream() .filter(r -> !left.equals(r)) .findFirst().get(); @@ -1392,7 +1473,7 @@ void testEquals() { EqualsVerifier .forClasses( Text.class, Append.class, Param.class, - Alternatives.class, Indent.class, + Alternatives.class, Indent.class, Margin.class, LineOr.class, Escape.class, Styled.class) .usingGetClass() .withPrefabValues(Doc.class, left, right) @@ -1403,11 +1484,11 @@ void testEquals() { void testToString() { ToStringVerifier .forClasses( - Text.class, Append.class, + Text.class, Append.class, Margin.class, Alternatives.class, Indent.class, LineOr.class, Empty.class, Escape.class, Reset.class, Styled.class, Param.class) - .withPrefabValue(Doc.class, docs().sample()) + .withPrefabValue(Doc.class, docsWithParams().sample()) .verify(); ToStringVerifier @@ -1416,16 +1497,6 @@ 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( @@ -1433,8 +1504,8 @@ Arbitrary> unaryDocs() { () -> 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)), + () -> docsWithSeparators().map(doc1 -> doc1::append), + () -> docsWithSeparators().map(doc1 -> doc2 -> doc2.append(doc1)), () -> unaryDocs().tuple2().map(tuple -> doc -> tuple.get1().andThen(tuple.get2()).apply(doc)) ); } @@ -1485,10 +1556,29 @@ Arbitrary colors() { } @Provide - Arbitrary docs() { + Arbitrary docsWithParams() { return Arbitraries.lazyOf( // Text + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + // Empty + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.just(Doc.empty()), + () -> Arbitraries.just(Doc.empty()), + // Append + () -> docsWithParams().tuple2().map(tuple -> tuple.get1().append(tuple.get2())), + // Margin + () -> docsWithParams().tuple2().filter(tuple -> !tuple.get2().hasLineSeparators()).map(tuple -> tuple.get1().margin(tuple.get2())), + // Indent + () -> docsWithParams().map(doc -> doc.indent(2)), + // Alternatives + () -> docsWithParams().map(Doc::group), + // Styled + () -> docsWithParams().flatMap(doc -> styles().array(Styles.StylesOperator[].class).ofMaxSize(5).map(doc::styled)), // Line () -> Arbitraries.just(Doc.line()), // LineOrSpace @@ -1496,44 +1586,99 @@ Arbitrary docs() { // LineOrEmpty () -> Arbitraries.just(Doc.lineOrEmpty()), // LineOr - () -> docs().map(Doc::lineOr), () -> Arbitraries.strings().ofMaxLength(10).map(Doc::lineOr), + () -> docsWithParams().map(Doc::lineOr), + // Bracketing + () -> docsWithParams().map(doc -> doc.bracket(2, Doc.lineOrEmpty(), Doc.text("["), Doc.text("]"))), + // Param + () -> Arbitraries.strings().map(Doc::param) + ); + } + + @Provide + Arbitrary docsWithSeparators() { + return Arbitraries.lazyOf( + // Text + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + // Empty + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.just(Doc.empty()), + () -> Arbitraries.just(Doc.empty()), + // Append + () -> docsWithSeparators().tuple2().map(tuple -> tuple.get1().append(tuple.get2())), + // Margin + () -> docsWithSeparators().tuple2().filter(tuple -> !tuple.get2().hasLineSeparators()).map(tuple -> tuple.get1().margin(tuple.get2())), + // Indent + () -> docsWithSeparators().map(doc -> doc.indent(2)), + // Alternatives + () -> docsWithSeparators().map(Doc::group), + // Styled + () -> docsWithSeparators().flatMap(doc -> styles().array(Styles.StylesOperator[].class).ofMaxSize(5).map(doc::styled)), + // Line + () -> Arbitraries.just(Doc.line()), + // LineOrSpace + () -> Arbitraries.just(Doc.lineOrSpace()), + // LineOrEmpty + () -> Arbitraries.just(Doc.lineOrEmpty()), + // LineOr + () -> Arbitraries.strings().ofMaxLength(10).map(Doc::lineOr), + () -> docsWithSeparators().map(Doc::lineOr), + // Bracketing + () -> docsWithSeparators().map(doc -> doc.bracket(2, Doc.lineOrEmpty(), Doc.text("["), Doc.text("]"))) + ); + } + + @Provide + Arbitrary docs() { + return Arbitraries.lazyOf( + // Text + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), + () -> Arbitraries.strings().ofMaxLength(100).map(Doc::text), // Empty + // Repeated to reduce the frequency of recursive generation + () -> Arbitraries.just(Doc.empty()), () -> Arbitraries.just(Doc.empty()), // Append () -> docs().tuple2().map(tuple -> tuple.get1().append(tuple.get2())), + // Margin + () -> docs().tuple2().filter(tuple -> !tuple.get2().hasLineSeparators()).map(tuple -> tuple.get1().margin(tuple.get2())), // Indent () -> docs().map(doc -> doc.indent(2)), - // Bracketing - () -> docs().map(doc -> doc.bracket(2, Doc.lineOrEmpty(), Doc.text("["), Doc.text("]"))), // Alternatives () -> 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)); - }) + () -> docs().flatMap(doc -> styles().array(Styles.StylesOperator[].class).ofMaxSize(5).map(doc::styled)) ); } - String sgrCode(int code) { - return AnsiConstants.CSI + code + 'm'; + String sgrCode(int ...codes) { + return Arrays.stream(codes) + .mapToObj(Integer::toString) + .collect(Collectors.joining(";", AnsiConstants.CSI, "m")); } String xtermFgCode(int code) { - return AnsiConstants.CSI + "38;5;" + code + 'm'; + return sgrCode(38, 5, code); } String xtermBgCode(int code) { - return AnsiConstants.CSI + "48;5;" + code + 'm'; + return sgrCode(48, 5, code); } String rgbFgCode(int r, int g, int b) { - return AnsiConstants.CSI + "38;2;" + r + ';' + g + ';' + b + 'm'; + return sgrCode(38, 2, r, g, b); } String rgbBgCode(int r, int g, int b) { - return AnsiConstants.CSI + "48;2;" + r + ';' + g + ';' + b + 'm'; + return sgrCode(48, 2, r, g, b); } }