Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions src/main/java/com/opencastsoftware/prettier4j/Doc.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: © 2022-2024 Opencast Software Europe Ltd <https://opencastsoftware.com>
* SPDX-FileCopyrightText: © 2022-2025 Opencast Software Europe Ltd <https://opencastsoftware.com>
* SPDX-License-Identifier: Apache-2.0
*/
package com.opencastsoftware.prettier4j;
Expand Down Expand Up @@ -236,6 +236,15 @@ public Doc indent(int indent) {
return indent(indent, this);
}

/**
* Align any line breaks within this {@link Doc} to the line position at the start of the {@link Doc}.
*
* @return the aligned document.
*/
public Doc align() {
return align(this);
}

/**
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit wary of adding too much stuff to this library, but it's possible to get "hanging" style indent by adding a variant of bracket with the following implementation:

        return group(align(
                left
                        .append(align(lineDoc.append(this).indent(indent).margin(marginDoc)))
                        .append(lineDoc.append(right))));

* Bracket the current document by the {@code left} and {@code right} Strings,
* indented by {@code indent} spaces.
Expand Down Expand Up @@ -849,6 +858,67 @@ public String toString() {
}
}

/**
* Represents an aligned {@link Doc}.
*
* Sets the indentation for line breaks within its inner {@link Doc} at the current line position.
*/
public static class Align extends Doc {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may also be worth adding an absolute Position variant of Align that takes a user-provided position argument to be applied after line breaks

private final Doc doc;

Align(Doc doc) {
this.doc = doc;
}

public Doc doc() {
return doc;
}

@Override
Doc flatten() {
return new Align(doc.flatten());
}

@Override
boolean hasParams() {
return doc.hasParams();
}

@Override
boolean hasLineSeparators() {
return doc.hasLineSeparators();
}

@Override
public Doc bind(String name, Doc value) {
return new Align(doc.bind(name, value));
}

@Override
public Doc bind(Map<String, Doc> bindings) {
return new Align(doc.bind(bindings));
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Align align = (Align) o;
return Objects.equals(doc, align.doc);
}

@Override
public int hashCode() {
return Objects.hashCode(doc);
}

@Override
public String toString() {
return "Align[" +
"doc=" + doc +
']';
}
}

/**
* Represents a line break which cannot be flattened into a more compact layout.
*/
Expand Down Expand Up @@ -1615,6 +1685,16 @@ public static Doc indent(int indent, Doc doc) {
return new Indent(indent, doc);
}

/**
* Align any line breaks within this {@link Doc} to the line position at the start of the {@link Doc}.
*
* @param doc the input document
* @return the aligned document.
*/
public static Doc align(Doc doc) {
return new Align(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
Expand Down Expand Up @@ -2073,6 +2153,10 @@ private static int layoutEntry(RenderOptions options, Deque<Entry> inQueue, Queu
Indent indentDoc = (Indent) entryDoc;
int newIndent = entryIndent + indentDoc.indent();
inQueue.addFirst(entry(newIndent, entryMargin, indentDoc.doc()));
} else if (entryDoc instanceof Align) {
// Eliminate Align
Align alignDoc = (Align) entryDoc;
inQueue.addFirst(entry(position, entryMargin, alignDoc.doc()));
} else if (entryDoc instanceof Margin) {
// Eliminate Margin
Margin marginDoc = (Margin) entryDoc;
Expand All @@ -2097,7 +2181,7 @@ private static int layoutEntry(RenderOptions options, Deque<Entry> inQueue, Queu
outQueue.add(topEntry);
} else if (entryDoc instanceof LineOr) {
// Reset line length
position = entryIndent;
position = 0;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is based on this observation that sending out the indent text below already manipulates the position to add the entryIndent.

I missed this while implementing Margin.

// Note reverse order
if (entryIndent > 0) {
// Send out the indent spaces
Expand Down
163 changes: 158 additions & 5 deletions src/test/java/com/opencastsoftware/prettier4j/DocTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: © 2022-2024 Opencast Software Europe Ltd <https://opencastsoftware.com>
* SPDX-FileCopyrightText: © 2022-2025 Opencast Software Europe Ltd <https://opencastsoftware.com>
* SPDX-License-Identifier: Apache-2.0
*/
package com.opencastsoftware.prettier4j;
Expand All @@ -21,10 +21,10 @@
import java.io.StringWriter;
import java.io.Writer;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.*;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.opencastsoftware.prettier4j.Doc.*;
import static org.hamcrest.MatcherAssert.assertThat;
Expand Down Expand Up @@ -113,6 +113,15 @@ void testAppendLineFlattening() {
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineWithAlign() {
String expected = "one two\n three";
String actual = text("one")
.appendSpace(group(align(text("two").appendLine(text("three")))))
.render(30);
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineOrSpace() {
String expected = "one two three";
Expand All @@ -123,6 +132,15 @@ void testAppendLineOrSpace() {
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineOrSpaceWithAlign() {
String expected = "one two three";
String actual = text("one")
.appendSpace(group(align(text("two").appendLineOrSpace(text("three")))))
.render(30);
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineOrSpaceFlattening() {
String expected = "one\ntwo\nthree";
Expand All @@ -133,6 +151,15 @@ void testAppendLineOrSpaceFlattening() {
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineOrSpaceWithAlignFlattening() {
String expected = "one two\n three";
String actual = text("one")
.appendSpace(group(align(text("two").appendLineOrSpace(text("three")))))
.render(10);
assertThat(actual, is(equalTo(expected)));
}

@Test
void testAppendLineOrEmpty() {
String expected = "onetwothree";
Expand Down Expand Up @@ -217,6 +244,132 @@ void testBracketFlattening() {
assertThat(actual, is(equalTo(expected)));
}

@Test
void testBracketFlatteningWithAlign() {
// Note: the arguments are aligned with the "functionCall" element because bracket doesn't support alignment.
// TODO: Consider adding a `hangingBracket` combinator that aligns the bracket docs with the starting line position
// and the arguments with the opening bracket doc.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hangingBracket refers to the variant of bracket I describe above

String expected = "functionCall(\n"+
Indents.get(12)+"a,\n"+
Indents.get(12)+"b,\n"+
Indents.get(12)+"c\n"+
Indents.get(12)+")";
String actual = text("functionCall")
.append(
Doc.intersperse(
Doc.text(",").append(Doc.lineOrSpace()),
Stream.of("a", "b", "c").map(Doc::text))
.bracket(0, Doc.lineOrEmpty(), Doc.text("("), Doc.text(")"))
.align())
.render(10);

assertThat(actual, is(equalTo(expected)));
}

@Test
void testNestedBracketFlattening() {
String expectedWidth80 = "let x = functionCall(with, args, nestedFunctionCall(with, more, args))";
String expectedWidth40 = "let x = functionCall(\n with,\n args,\n nestedFunctionCall(with, more, args)\n)";
String expectedWidth20 = "let x = functionCall(\n with,\n args,\n nestedFunctionCall(\n with,\n more,\n args\n )\n)";

Doc inputDoc = text("let")
.appendSpace(text("x"))
.appendSpace(text("="))
.appendSpace(text("functionCall")
.append(
intersperse(
text(",").append(lineOrSpace()),
Stream.concat(
Stream.of("with", "args").map(Doc::text),
Stream.of(text("nestedFunctionCall")
.append(
intersperse(
text(",").append(lineOrSpace()),
Stream.of("with", "more", "args").map(Doc::text)
).bracket(2, lineOrEmpty(), text("("), text(")"))
))
)
).bracket(2, lineOrEmpty(), text("("), text(")"))
)
);

assertThat(inputDoc.render(80), is(equalTo(expectedWidth80)));
assertThat(inputDoc.render(40), is(equalTo(expectedWidth40)));
assertThat(inputDoc.render(20), is(equalTo(expectedWidth20)));
}

@Test
void testNestedBracketFlatteningWithAlign() {
String expectedWidth80 = "let x = functionCall(with, args, nestedFunctionCall(with, more, args))";
String expectedWidth40 =
"let x = functionCall(\n"+
Indents.get(20)+"with,\n"+
Indents.get(20)+"args,\n"+
Indents.get(20)+"nestedFunctionCall(\n"+
Indents.get(38)+"with,\n"+
Indents.get(38)+"more,\n"+
Indents.get(38)+ "args\n"+
Indents.get(38)+")\n"+
Indents.get(20)+")";

Doc inputDoc = text("let")
.appendSpace(text("x"))
.appendSpace(text("="))
.appendSpace(text("functionCall")
.append(align(
intersperse(
text(",").append(lineOrSpace()),
Stream.concat(
Stream.of("with", "args").map(Doc::text),
Stream.of(text("nestedFunctionCall")
.append(align(
intersperse(
text(",").append(lineOrSpace()),
Stream.of("with", "more", "args").map(Doc::text)
).bracket(0, lineOrEmpty(), text("("), text(")"))
)))
)
).bracket(0, lineOrEmpty(), text("("), text(")"))
))
);

assertThat(inputDoc.render(80), is(equalTo(expectedWidth80)));
assertThat(inputDoc.render(40), is(equalTo(expectedWidth40)));
}

@Test
void testAlignWithMultipleLines() {
String expected =
"∧ ∨ A ∨ B\n" +
" ∨ C\n" +
"∧ ∨ D\n" +
" ∨ E ∧ F\n" +
" ∨ G";

// (A ∨ B) ∨ C
List<Doc> left = List.of(
text("A").appendSpace(text("∨")).appendSpace(text("B")),
text("C")
);

// D ∨ (E ∧ F) ∨ G
List<Doc> right = List.of(
text("D"),
text("E").appendSpace(text("∧")).appendSpace(text("F")),
text("G")
);

Doc alignedLeft = align(text("∨").appendSpace(intersperse(line().append(text("∨ ")), left)));
Doc leftJunctions = text("∧").appendSpace(alignedLeft);

Doc alignedRight = align(text("∨").appendSpace(intersperse(line().append(text("∨ ")), right)));
Doc rightJunctions = text("∧").appendSpace(alignedRight);

String result = leftJunctions.appendLine(rightJunctions).render(80);

assertThat(result, is(equalTo(expected)));
}

@Test
void testMarginWithLineSeparator() {
assertThrows(IllegalArgumentException.class, () -> {
Expand Down Expand Up @@ -1549,7 +1702,7 @@ void testEquals() {
EqualsVerifier
.forClasses(
Text.class, Append.class, Param.class, WrapText.class,
Alternatives.class, Indent.class, Margin.class, Link.class,
Alternatives.class, Indent.class, Align.class, Margin.class, Link.class,
LineOr.class, Escape.class, Styled.class, OpenLink.class)
.usingGetClass()
.withPrefabValues(Doc.class, left, right)
Expand All @@ -1562,7 +1715,7 @@ void testToString() {
.forClasses(
Text.class, Append.class, Margin.class,
WrapText.class, Alternatives.class, Indent.class,
LineOr.class, Empty.class, Escape.class,
LineOr.class, Empty.class, Escape.class, Align.class,
Link.class, OpenLink.class, CloseLink.class,
Reset.class, Styled.class, Param.class)
.withPrefabValue(Doc.class, docsWithParams().sample())
Expand Down