Skip to content

Commit 48a56fc

Browse files
Merge pull request #155 from opencastsoftware/align-document
Add `Doc.align` that aligns subsequent line breaks to the current line position
2 parents 40a9cdf + 7e33de9 commit 48a56fc

File tree

2 files changed

+244
-7
lines changed
  • src
    • main/java/com/opencastsoftware/prettier4j
    • test/java/com/opencastsoftware/prettier4j

2 files changed

+244
-7
lines changed

src/main/java/com/opencastsoftware/prettier4j/Doc.java

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: © 2022-2024 Opencast Software Europe Ltd <https://opencastsoftware.com>
2+
* SPDX-FileCopyrightText: © 2022-2025 Opencast Software Europe Ltd <https://opencastsoftware.com>
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
package com.opencastsoftware.prettier4j;
@@ -236,6 +236,15 @@ public Doc indent(int indent) {
236236
return indent(indent, this);
237237
}
238238

239+
/**
240+
* Align any line breaks within this {@link Doc} to the line position at the start of the {@link Doc}.
241+
*
242+
* @return the aligned document.
243+
*/
244+
public Doc align() {
245+
return align(this);
246+
}
247+
239248
/**
240249
* Bracket the current document by the {@code left} and {@code right} Strings,
241250
* indented by {@code indent} spaces.
@@ -849,6 +858,67 @@ public String toString() {
849858
}
850859
}
851860

861+
/**
862+
* Represents an aligned {@link Doc}.
863+
*
864+
* Sets the indentation for line breaks within its inner {@link Doc} at the current line position.
865+
*/
866+
public static class Align extends Doc {
867+
private final Doc doc;
868+
869+
Align(Doc doc) {
870+
this.doc = doc;
871+
}
872+
873+
public Doc doc() {
874+
return doc;
875+
}
876+
877+
@Override
878+
Doc flatten() {
879+
return new Align(doc.flatten());
880+
}
881+
882+
@Override
883+
boolean hasParams() {
884+
return doc.hasParams();
885+
}
886+
887+
@Override
888+
boolean hasLineSeparators() {
889+
return doc.hasLineSeparators();
890+
}
891+
892+
@Override
893+
public Doc bind(String name, Doc value) {
894+
return new Align(doc.bind(name, value));
895+
}
896+
897+
@Override
898+
public Doc bind(Map<String, Doc> bindings) {
899+
return new Align(doc.bind(bindings));
900+
}
901+
902+
@Override
903+
public boolean equals(Object o) {
904+
if (o == null || getClass() != o.getClass()) return false;
905+
Align align = (Align) o;
906+
return Objects.equals(doc, align.doc);
907+
}
908+
909+
@Override
910+
public int hashCode() {
911+
return Objects.hashCode(doc);
912+
}
913+
914+
@Override
915+
public String toString() {
916+
return "Align[" +
917+
"doc=" + doc +
918+
']';
919+
}
920+
}
921+
852922
/**
853923
* Represents a line break which cannot be flattened into a more compact layout.
854924
*/
@@ -1615,6 +1685,16 @@ public static Doc indent(int indent, Doc doc) {
16151685
return new Indent(indent, doc);
16161686
}
16171687

1688+
/**
1689+
* Align any line breaks within this {@link Doc} to the line position at the start of the {@link Doc}.
1690+
*
1691+
* @param doc the input document
1692+
* @return the aligned document.
1693+
*/
1694+
public static Doc align(Doc doc) {
1695+
return new Align(doc);
1696+
}
1697+
16181698
/**
16191699
* Apply the margin document {@code margin} to the current {@link Doc}, emitting the
16201700
* margin at the start of every new line from the start of this document until the
@@ -2073,6 +2153,10 @@ private static int layoutEntry(RenderOptions options, Deque<Entry> inQueue, Queu
20732153
Indent indentDoc = (Indent) entryDoc;
20742154
int newIndent = entryIndent + indentDoc.indent();
20752155
inQueue.addFirst(entry(newIndent, entryMargin, indentDoc.doc()));
2156+
} else if (entryDoc instanceof Align) {
2157+
// Eliminate Align
2158+
Align alignDoc = (Align) entryDoc;
2159+
inQueue.addFirst(entry(position, entryMargin, alignDoc.doc()));
20762160
} else if (entryDoc instanceof Margin) {
20772161
// Eliminate Margin
20782162
Margin marginDoc = (Margin) entryDoc;
@@ -2097,7 +2181,7 @@ private static int layoutEntry(RenderOptions options, Deque<Entry> inQueue, Queu
20972181
outQueue.add(topEntry);
20982182
} else if (entryDoc instanceof LineOr) {
20992183
// Reset line length
2100-
position = entryIndent;
2184+
position = 0;
21012185
// Note reverse order
21022186
if (entryIndent > 0) {
21032187
// Send out the indent spaces

src/test/java/com/opencastsoftware/prettier4j/DocTest.java

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: © 2022-2024 Opencast Software Europe Ltd <https://opencastsoftware.com>
2+
* SPDX-FileCopyrightText: © 2022-2025 Opencast Software Europe Ltd <https://opencastsoftware.com>
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
package com.opencastsoftware.prettier4j;
@@ -21,10 +21,10 @@
2121
import java.io.StringWriter;
2222
import java.io.Writer;
2323
import java.net.URI;
24-
import java.util.Arrays;
25-
import java.util.Collections;
24+
import java.util.*;
2625
import java.util.function.UnaryOperator;
2726
import java.util.stream.Collectors;
27+
import java.util.stream.Stream;
2828

2929
import static com.opencastsoftware.prettier4j.Doc.*;
3030
import static org.hamcrest.MatcherAssert.assertThat;
@@ -113,6 +113,15 @@ void testAppendLineFlattening() {
113113
assertThat(actual, is(equalTo(expected)));
114114
}
115115

116+
@Test
117+
void testAppendLineWithAlign() {
118+
String expected = "one two\n three";
119+
String actual = text("one")
120+
.appendSpace(group(align(text("two").appendLine(text("three")))))
121+
.render(30);
122+
assertThat(actual, is(equalTo(expected)));
123+
}
124+
116125
@Test
117126
void testAppendLineOrSpace() {
118127
String expected = "one two three";
@@ -123,6 +132,15 @@ void testAppendLineOrSpace() {
123132
assertThat(actual, is(equalTo(expected)));
124133
}
125134

135+
@Test
136+
void testAppendLineOrSpaceWithAlign() {
137+
String expected = "one two three";
138+
String actual = text("one")
139+
.appendSpace(group(align(text("two").appendLineOrSpace(text("three")))))
140+
.render(30);
141+
assertThat(actual, is(equalTo(expected)));
142+
}
143+
126144
@Test
127145
void testAppendLineOrSpaceFlattening() {
128146
String expected = "one\ntwo\nthree";
@@ -133,6 +151,15 @@ void testAppendLineOrSpaceFlattening() {
133151
assertThat(actual, is(equalTo(expected)));
134152
}
135153

154+
@Test
155+
void testAppendLineOrSpaceWithAlignFlattening() {
156+
String expected = "one two\n three";
157+
String actual = text("one")
158+
.appendSpace(group(align(text("two").appendLineOrSpace(text("three")))))
159+
.render(10);
160+
assertThat(actual, is(equalTo(expected)));
161+
}
162+
136163
@Test
137164
void testAppendLineOrEmpty() {
138165
String expected = "onetwothree";
@@ -217,6 +244,132 @@ void testBracketFlattening() {
217244
assertThat(actual, is(equalTo(expected)));
218245
}
219246

247+
@Test
248+
void testBracketFlatteningWithAlign() {
249+
// Note: the arguments are aligned with the "functionCall" element because bracket doesn't support alignment.
250+
// TODO: Consider adding a `hangingBracket` combinator that aligns the bracket docs with the starting line position
251+
// and the arguments with the opening bracket doc.
252+
String expected = "functionCall(\n"+
253+
Indents.get(12)+"a,\n"+
254+
Indents.get(12)+"b,\n"+
255+
Indents.get(12)+"c\n"+
256+
Indents.get(12)+")";
257+
String actual = text("functionCall")
258+
.append(
259+
Doc.intersperse(
260+
Doc.text(",").append(Doc.lineOrSpace()),
261+
Stream.of("a", "b", "c").map(Doc::text))
262+
.bracket(0, Doc.lineOrEmpty(), Doc.text("("), Doc.text(")"))
263+
.align())
264+
.render(10);
265+
266+
assertThat(actual, is(equalTo(expected)));
267+
}
268+
269+
@Test
270+
void testNestedBracketFlattening() {
271+
String expectedWidth80 = "let x = functionCall(with, args, nestedFunctionCall(with, more, args))";
272+
String expectedWidth40 = "let x = functionCall(\n with,\n args,\n nestedFunctionCall(with, more, args)\n)";
273+
String expectedWidth20 = "let x = functionCall(\n with,\n args,\n nestedFunctionCall(\n with,\n more,\n args\n )\n)";
274+
275+
Doc inputDoc = text("let")
276+
.appendSpace(text("x"))
277+
.appendSpace(text("="))
278+
.appendSpace(text("functionCall")
279+
.append(
280+
intersperse(
281+
text(",").append(lineOrSpace()),
282+
Stream.concat(
283+
Stream.of("with", "args").map(Doc::text),
284+
Stream.of(text("nestedFunctionCall")
285+
.append(
286+
intersperse(
287+
text(",").append(lineOrSpace()),
288+
Stream.of("with", "more", "args").map(Doc::text)
289+
).bracket(2, lineOrEmpty(), text("("), text(")"))
290+
))
291+
)
292+
).bracket(2, lineOrEmpty(), text("("), text(")"))
293+
)
294+
);
295+
296+
assertThat(inputDoc.render(80), is(equalTo(expectedWidth80)));
297+
assertThat(inputDoc.render(40), is(equalTo(expectedWidth40)));
298+
assertThat(inputDoc.render(20), is(equalTo(expectedWidth20)));
299+
}
300+
301+
@Test
302+
void testNestedBracketFlatteningWithAlign() {
303+
String expectedWidth80 = "let x = functionCall(with, args, nestedFunctionCall(with, more, args))";
304+
String expectedWidth40 =
305+
"let x = functionCall(\n"+
306+
Indents.get(20)+"with,\n"+
307+
Indents.get(20)+"args,\n"+
308+
Indents.get(20)+"nestedFunctionCall(\n"+
309+
Indents.get(38)+"with,\n"+
310+
Indents.get(38)+"more,\n"+
311+
Indents.get(38)+ "args\n"+
312+
Indents.get(38)+")\n"+
313+
Indents.get(20)+")";
314+
315+
Doc inputDoc = text("let")
316+
.appendSpace(text("x"))
317+
.appendSpace(text("="))
318+
.appendSpace(text("functionCall")
319+
.append(align(
320+
intersperse(
321+
text(",").append(lineOrSpace()),
322+
Stream.concat(
323+
Stream.of("with", "args").map(Doc::text),
324+
Stream.of(text("nestedFunctionCall")
325+
.append(align(
326+
intersperse(
327+
text(",").append(lineOrSpace()),
328+
Stream.of("with", "more", "args").map(Doc::text)
329+
).bracket(0, lineOrEmpty(), text("("), text(")"))
330+
)))
331+
)
332+
).bracket(0, lineOrEmpty(), text("("), text(")"))
333+
))
334+
);
335+
336+
assertThat(inputDoc.render(80), is(equalTo(expectedWidth80)));
337+
assertThat(inputDoc.render(40), is(equalTo(expectedWidth40)));
338+
}
339+
340+
@Test
341+
void testAlignWithMultipleLines() {
342+
String expected =
343+
"∧ ∨ A ∨ B\n" +
344+
" ∨ C\n" +
345+
"∧ ∨ D\n" +
346+
" ∨ E ∧ F\n" +
347+
" ∨ G";
348+
349+
// (A ∨ B) ∨ C
350+
List<Doc> left = List.of(
351+
text("A").appendSpace(text("∨")).appendSpace(text("B")),
352+
text("C")
353+
);
354+
355+
// D ∨ (E ∧ F) ∨ G
356+
List<Doc> right = List.of(
357+
text("D"),
358+
text("E").appendSpace(text("∧")).appendSpace(text("F")),
359+
text("G")
360+
);
361+
362+
Doc alignedLeft = align(text("∨").appendSpace(intersperse(line().append(text("∨ ")), left)));
363+
Doc leftJunctions = text("∧").appendSpace(alignedLeft);
364+
365+
Doc alignedRight = align(text("∨").appendSpace(intersperse(line().append(text("∨ ")), right)));
366+
Doc rightJunctions = text("∧").appendSpace(alignedRight);
367+
368+
String result = leftJunctions.appendLine(rightJunctions).render(80);
369+
370+
assertThat(result, is(equalTo(expected)));
371+
}
372+
220373
@Test
221374
void testMarginWithLineSeparator() {
222375
assertThrows(IllegalArgumentException.class, () -> {
@@ -1549,7 +1702,7 @@ void testEquals() {
15491702
EqualsVerifier
15501703
.forClasses(
15511704
Text.class, Append.class, Param.class, WrapText.class,
1552-
Alternatives.class, Indent.class, Margin.class, Link.class,
1705+
Alternatives.class, Indent.class, Align.class, Margin.class, Link.class,
15531706
LineOr.class, Escape.class, Styled.class, OpenLink.class)
15541707
.usingGetClass()
15551708
.withPrefabValues(Doc.class, left, right)
@@ -1562,7 +1715,7 @@ void testToString() {
15621715
.forClasses(
15631716
Text.class, Append.class, Margin.class,
15641717
WrapText.class, Alternatives.class, Indent.class,
1565-
LineOr.class, Empty.class, Escape.class,
1718+
LineOr.class, Empty.class, Escape.class, Align.class,
15661719
Link.class, OpenLink.class, CloseLink.class,
15671720
Reset.class, Styled.class, Param.class)
15681721
.withPrefabValue(Doc.class, docsWithParams().sample())

0 commit comments

Comments
 (0)