diff --git a/core/src/main/java/com/google/googlejavaformat/Doc.java b/core/src/main/java/com/google/googlejavaformat/Doc.java index 54c7da6c7..6c632666a 100644 --- a/core/src/main/java/com/google/googlejavaformat/Doc.java +++ b/core/src/main/java/com/google/googlejavaformat/Doc.java @@ -365,7 +365,7 @@ public enum RealOrImaginary { REAL, IMAGINARY; - boolean isReal() { + public boolean isReal() { return this == REAL; } } @@ -395,12 +395,12 @@ private Token( * * @return the extra indent */ - Indent getPlusIndentCommentsBefore() { + public Indent getPlusIndentCommentsBefore() { return plusIndentCommentsBefore; } /** Force a line break and indent trailing javadoc or block comments. */ - Optional breakAndIndentTrailingComment() { + public Optional breakAndIndentTrailingComment() { return breakAndIndentTrailingComment; } @@ -427,7 +427,7 @@ static Op make( * * @return the {@link Input.Token} */ - Input.Token getToken() { + public Input.Token getToken() { return token; } @@ -436,7 +436,7 @@ Input.Token getToken() { * * @return whether the @code Token} is good */ - RealOrImaginary realOrImaginary() { + public RealOrImaginary realOrImaginary() { return realOrImaginary; } @@ -483,7 +483,7 @@ public String toString() { } /** A Leaf node in a {@link Doc} for a non-breaking space. */ - static final class Space extends Doc implements Op { + public static final class Space extends Doc implements Op { private static final Space SPACE = new Space(); private Space() {} @@ -600,6 +600,22 @@ boolean isForced() { return fillMode == FillMode.FORCED; } + public FillMode fillMode() { + return fillMode; + } + + public String flat() { + return flat; + } + + public Indent plusIndent() { + return plusIndent; + } + + public Optional optTag() { + return optTag; + } + @Override public void add(DocBuilder builder) { builder.breakDoc(this); diff --git a/core/src/main/java/com/google/googlejavaformat/Indent.java b/core/src/main/java/com/google/googlejavaformat/Indent.java index 44b56cdd6..3c12398f6 100644 --- a/core/src/main/java/com/google/googlejavaformat/Indent.java +++ b/core/src/main/java/com/google/googlejavaformat/Indent.java @@ -24,7 +24,7 @@ */ public abstract class Indent { - abstract int eval(); + public abstract int eval(); /** A constant function, returning a constant indent. */ public static final class Const extends Indent { @@ -41,7 +41,7 @@ public static Const make(int n, int indentMultiplier) { } @Override - int eval() { + public int eval() { return n; } @@ -68,7 +68,7 @@ public static If make(BreakTag condition, Indent thenIndent, Indent elseIndent) } @Override - int eval() { + public int eval() { return (condition.wasBreakTaken() ? thenIndent : elseIndent).eval(); } diff --git a/core/src/main/java/com/google/googlejavaformat/Newlines.java b/core/src/main/java/com/google/googlejavaformat/Newlines.java index 6a1241c36..20c51b558 100644 --- a/core/src/main/java/com/google/googlejavaformat/Newlines.java +++ b/core/src/main/java/com/google/googlejavaformat/Newlines.java @@ -35,6 +35,21 @@ public static int firstBreak(String input) { return it.hasNext() ? it.next() : -1; } + /** + * Returns the offset of the start of the last line in the input, or {@code 0} if the input + * contains no line breaks. Unlike iterating over {@link #lineOffsetIterator}, this only scans the + * last line. + */ + public static int lastLineStart(String input) { + for (int i = input.length() - 1; i >= 0; i--) { + char c = input.charAt(i); + if (c == '\n' || c == '\r') { + return i + 1; + } + } + return 0; + } + private static final ImmutableSet BREAKS = ImmutableSet.of("\r\n", "\n", "\r"); /** Returns true if the entire input string is a recognized line break. */ diff --git a/core/src/main/java/com/google/googlejavaformat/OpenOp.java b/core/src/main/java/com/google/googlejavaformat/OpenOp.java index 649820131..e4f86f1a5 100644 --- a/core/src/main/java/com/google/googlejavaformat/OpenOp.java +++ b/core/src/main/java/com/google/googlejavaformat/OpenOp.java @@ -44,6 +44,10 @@ public void add(DocBuilder builder) { builder.open(plusIndent); } + public Indent plusIndent() { + return plusIndent; + } + @Override public String toString() { return MoreObjects.toStringHelper(this).add("plusIndent", plusIndent).toString(); diff --git a/core/src/main/java/com/google/googlejavaformat/java/Formatter.java b/core/src/main/java/com/google/googlejavaformat/java/Formatter.java index e8ca411d1..c55b2a7ff 100644 --- a/core/src/main/java/com/google/googlejavaformat/java/Formatter.java +++ b/core/src/main/java/com/google/googlejavaformat/java/Formatter.java @@ -25,11 +25,11 @@ import com.google.errorprone.annotations.Immutable; import com.google.googlejavaformat.CommentsHelper; import com.google.googlejavaformat.Doc; -import com.google.googlejavaformat.DocBuilder; import com.google.googlejavaformat.FormattingError; import com.google.googlejavaformat.Newlines; import com.google.googlejavaformat.Op; -import com.google.googlejavaformat.OpsBuilder; +import com.google.googlejavaformat.layout.LayoutBuilder; +import com.google.googlejavaformat.layout.LayoutSolver; import com.sun.tools.javac.tree.JCTree.JCCompilationUnit; import com.sun.tools.javac.util.Context; import java.io.IOException; @@ -113,7 +113,7 @@ static void format(final JavaInput javaInput, JavaOutput javaOutput, JavaFormatt if (!errorDiagnostics.isEmpty()) { throw FormatterException.fromJavacDiagnostics(errorDiagnostics); } - OpsBuilder builder = new OpsBuilder(javaInput, javaOutput); + LayoutBuilder builder = new LayoutBuilder(javaInput, javaOutput); ImmutableSet.Builder markdownJavadocPositions = ImmutableSet.builder(); // Output the compilation unit. JavaInputAstVisitor visitor = @@ -121,14 +121,13 @@ static void format(final JavaInput javaInput, JavaOutput javaOutput, JavaFormatt visitor.scan(unit, null); builder.sync(javaInput.getText().length()); builder.drain(); - Doc doc = new DocBuilder().withOps(builder.build()).build(); + var doc = builder.build(); CommentsHelper commentsHelper = new JavaCommentsHelper( Newlines.guessLineSeparator(javaInput.getText()), options, markdownJavadocPositions.build()); - doc.computeBreaks(commentsHelper, options.maxLineLength(), new Doc.State(+0, 0)); - doc.write(javaOutput); + LayoutSolver.format(doc, options.maxLineLength(), javaOutput, commentsHelper); javaOutput.flush(); } diff --git a/core/src/main/java/com/google/googlejavaformat/java/JavaInputAstVisitor.java b/core/src/main/java/com/google/googlejavaformat/java/JavaInputAstVisitor.java index 891aa6c28..bae72bb1e 100644 --- a/core/src/main/java/com/google/googlejavaformat/java/JavaInputAstVisitor.java +++ b/core/src/main/java/com/google/googlejavaformat/java/JavaInputAstVisitor.java @@ -78,11 +78,11 @@ import com.google.googlejavaformat.Newlines; import com.google.googlejavaformat.Op; import com.google.googlejavaformat.OpenOp; -import com.google.googlejavaformat.OpsBuilder; import com.google.googlejavaformat.OpsBuilder.BlankLineWanted; import com.google.googlejavaformat.Output.BreakTag; import com.google.googlejavaformat.java.DimensionHelpers.SortedDims; import com.google.googlejavaformat.java.DimensionHelpers.TypeWithDims; +import com.google.googlejavaformat.layout.LayoutBuilder; import com.sun.source.tree.AnnotatedTypeTree; import com.sun.source.tree.AnnotationTree; import com.sun.source.tree.ArrayAccessTree; @@ -272,7 +272,7 @@ private static ImmutableSetMultimap typeAnnotations() { return result.build(); } - private final OpsBuilder builder; + private final LayoutBuilder builder; private final ImmutableSet.Builder markdownJavadocPositions; private static final Indent.Const ZERO = Indent.Const.ZERO; @@ -308,10 +308,10 @@ private static final ImmutableList forceBreakList(Optional breakTa /** * The {@code Visitor} constructor. * - * @param builder the {@link OpsBuilder} + * @param builder the {@link LayoutBuilder} */ JavaInputAstVisitor( - OpsBuilder builder, + LayoutBuilder builder, int indentMultiplier, ImmutableSet.Builder markdownJavadocPositions) { this.builder = builder; diff --git a/core/src/main/java/com/google/googlejavaformat/layout/Doc.java b/core/src/main/java/com/google/googlejavaformat/layout/Doc.java new file mode 100644 index 000000000..687651959 --- /dev/null +++ b/core/src/main/java/com/google/googlejavaformat/layout/Doc.java @@ -0,0 +1,263 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import static java.util.Objects.requireNonNull; + +import com.google.common.collect.DiscreteDomain; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Range; +import com.google.googlejavaformat.CommentsHelper; +import com.google.googlejavaformat.Doc.Token.RealOrImaginary; +import com.google.googlejavaformat.Indent; +import com.google.googlejavaformat.Input; +import com.google.googlejavaformat.Newlines; +import com.google.googlejavaformat.Output.BreakTag; +import java.util.List; +import java.util.Optional; + +/// An immutable document describing the layout choices available for a piece of source. +/// +/// The vocabulary follows Wadler's [A prettier +/// printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf) (2003): a [Text] is +/// `text`, a [Group] is `group` combined with `nest` (the amount of nesting is the group's +/// `plusIndent`), and a [BreakMode#UNIFIED] [Break] is `line`. As with `group x = flatten x <|> x` +/// (Wadler §2), a group is laid out either entirely flat or with all of its unified breaks taken. +/// Unlike Wadler's `line`, a break's flat text is arbitrary (often `""` or `" "`). +/// +/// A document is a tree of [Group]s. The leaves are [Text], [Tok], [Space] and [Break]. The layout +/// rules are implemented by [LayoutSolver]: +/// +/// - A group whose flat width fits in the remaining line is laid out flat, i.e. every break in it +/// (including nested ones) takes its flat text. A group that contains a [BreakMode#FORCED] break +/// never fits (its [#width] is [#MAX_LINE_WIDTH]), so this case never applies to it. +/// - Otherwise the group's direct breaks split it into segments, and each break is decided by its +/// [BreakMode]: [BreakMode#UNIFIED] and [BreakMode#FORCED] breaks are always taken, and +/// [BreakMode#FILL] breaks are taken only if the following segment does not fit flat on the +/// current line, or if the preceding segment did not fit. +/// +/// Fitting a group only considers its own width. This is the main difference from Wadler's `fits`, +/// which measures the whole first line of the remaining document, including any text after the +/// group. Trailing text that must stay on the same line as a group (e.g. a `;`) is instead placed +/// inside the group by [LayoutBuilder]. These are the rules of the existing +/// [com.google.googlejavaformat.Doc] engine, and are kept for output compatibility. +public sealed interface Doc permits Doc.Text, Doc.Tok, Doc.Space, Doc.Break, Doc.Group { + + /// Width used for documents that cannot be laid out on a single line. + int MAX_LINE_WIDTH = 1000; + + /// Range for documents that do not correspond to input tokens. + Range EMPTY_RANGE = Range.closedOpen(-1, -1); + + /// The width of this document laid out flat, or [#MAX_LINE_WIDTH] if it can't be. + int width(); + + /// Appends the text of this document laid out flat to `sb`. Only defined if `width() < + /// MAX_LINE_WIDTH`. + void appendFlat(StringBuilder sb); + + /// The text of this document laid out flat. Only defined if `width() < MAX_LINE_WIDTH`. + default String flat() { + StringBuilder sb = new StringBuilder(); + appendFlat(sb); + return sb.toString(); + } + + /// The half-open range of input token indices covered by this document, or [#EMPTY_RANGE] if it + /// covers no tokens. [LayoutSolver] reports it with the text it emits so that the output can be + /// mapped back to the input for partial formatting. + Range range(); + + /// How a [Break] is decided once its enclosing group does not fit. A group that contains a + /// [#FORCED] break is never considered to fit. + /// + /// The modes are not prioritized against each other. The priority between breaks comes from the + /// group hierarchy: an enclosing group is decided before its nested groups, and a nested group + /// that still fits on the remaining line stays flat. A group's direct breaks almost always share + /// one mode (apart from [#FORCED] breaks around comments), so the mode effectively describes how + /// the whole group breaks. + enum BreakMode { + /// Taken whenever the enclosing group does not fit, so all of the unified breaks in a group are + /// taken together: the group is laid out either entirely flat or with every unified break + /// taken. This is Wadler's `line` inside a `group`, and Oppen's "consistent" breaking. + UNIFIED, + /// Decided independently for each break: taken if the following segment doesn't fit on the + /// current line, or if the preceding segment didn't fit. A group of fill breaks packs as many + /// segments as fit onto each line, like Wadler's `fill` and Oppen's "inconsistent" breaking. + FILL, + /// Always taken. A group containing a forced break never fits. + FORCED + } + + /// An input token. + record Text(String text, Range range) implements Doc { + public Text { + requireNonNull(text, "text"); + requireNonNull(range, "range"); + } + + public Text(String text) { + this(text, EMPTY_RANGE); + } + + @Override + public int width() { + return Newlines.firstBreak(text) >= 0 ? MAX_LINE_WIDTH : text.length(); + } + + @Override + public void appendFlat(StringBuilder sb) { + sb.append(text); + } + } + + /// Input text that isn't a token (the equivalent of the `Doc.Tok` leaf in + /// [com.google.googlejavaformat.Doc]). This is usually a comment. It can also be whitespace or + /// newlines around an *imaginary* token ([RealOrImaginary#IMAGINARY]): a token the AST visitor + /// never emitted, such as the end-of-input token, which [LayoutBuilder#drain] reproduces verbatim + /// together with the whitespace and comments around it. + /// + /// When its enclosing group is broken a comment is re-formatted by a [CommentsHelper] for the + /// column it starts at; otherwise it's written as [#flat]. + record Tok(Input.Tok tok, Range range) implements Doc { + public Tok { + requireNonNull(tok, "tok"); + requireNonNull(range, "range"); + } + + public Tok(Input.Tok tok) { + this(tok, Range.singleton(tok.getIndex()).canonical(DiscreteDomain.integers())); + } + + /// Only the first line of a multi-line comment counts towards the width (more precisely, the + /// offset of its second line). Any other text that contains a newline never fits. + @Override + public int width() { + int idx = Newlines.firstBreak(tok.getOriginalText()); + if (idx == -1) { + return flat().length(); + } + return tok.isComment() ? idx : MAX_LINE_WIDTH; + } + + @Override + public void appendFlat(StringBuilder sb) { + sb.append(flat()); + } + + @Override + public String flat() { + if (tok.isSlashSlashComment() && !tok.getOriginalText().startsWith("// ")) { + return "// " + tok.getOriginalText().substring("//".length()); + } + return CommentsHelper.reformatParameterComment(tok).orElse(tok.getOriginalText()); + } + } + + /// A single space that is never broken. + enum Space implements Doc { + INSTANCE; + + @Override + public int width() { + return 1; + } + + @Override + public void appendFlat(StringBuilder sb) { + sb.append(' '); + } + + @Override + public Range range() { + return EMPTY_RANGE; + } + } + + /// A possible line break. When not taken it is written as `flatText`; when taken the next line is + /// indented by the enclosing group's indent plus `plusIndent`. + record Break(BreakMode mode, String flatText, Indent plusIndent, Optional tag) + implements Doc { + public Break { + requireNonNull(mode, "mode"); + requireNonNull(flatText, "flatText"); + requireNonNull(plusIndent, "plusIndent"); + requireNonNull(tag, "tag"); + } + + @Override + public int width() { + return mode == BreakMode.FORCED ? MAX_LINE_WIDTH : flatText.length(); + } + + @Override + public void appendFlat(StringBuilder sb) { + sb.append(flatText); + } + + @Override + public Range range() { + return EMPTY_RANGE; + } + } + + /// A unit of layout: either laid out flat, or split at its direct [Break]s. When broken, the + /// indent for its breaks is the enclosing indent plus `plusIndent`. + record Group(Indent plusIndent, ImmutableList docs, int width, Range range) + implements Doc { + public Group { + requireNonNull(plusIndent, "plusIndent"); + requireNonNull(docs, "docs"); + requireNonNull(range, "range"); + } + + public Group(Indent plusIndent, ImmutableList docs) { + this(plusIndent, docs, totalWidth(docs), computeRange(docs)); + } + + /// Returns the sum of the widths of `docs`, saturating at [#MAX_LINE_WIDTH]. + static int totalWidth(List docs) { + int width = 0; + for (Doc doc : docs) { + width += doc.width(); + if (width >= MAX_LINE_WIDTH) { + return MAX_LINE_WIDTH; + } + } + return width; + } + + /// Returns the smallest range enclosing the token ranges of all of `docs`, ignoring children + /// that cover no tokens; or [#EMPTY_RANGE] if none of them do. + private static Range computeRange(ImmutableList docs) { + Range result = EMPTY_RANGE; + for (Doc doc : docs) { + Range r = doc.range(); + if (r.isEmpty()) { + continue; + } + result = result.isEmpty() ? r : result.span(r).canonical(DiscreteDomain.integers()); + } + return result; + } + + @Override + public void appendFlat(StringBuilder sb) { + for (Doc doc : docs) { + doc.appendFlat(sb); + } + } + } +} diff --git a/core/src/main/java/com/google/googlejavaformat/layout/Docs.java b/core/src/main/java/com/google/googlejavaformat/layout/Docs.java new file mode 100644 index 000000000..d4723e399 --- /dev/null +++ b/core/src/main/java/com/google/googlejavaformat/layout/Docs.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import com.google.common.collect.ImmutableList; +import com.google.googlejavaformat.Indent; +import com.google.googlejavaformat.Output.BreakTag; +import java.util.Optional; + +/// Factory methods for constructing [Doc]s. +/// +/// The names follow Wadler's [A prettier +/// printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf) (`text`, `line`, +/// `group`, `fill`) and Leijen's wl-pprint library (`softline`, `hardline`). +public final class Docs { + + private Docs() {} + + public static Doc text(String text) { + return new Doc.Text(text); + } + + public static Doc space() { + return Doc.Space.INSTANCE; + } + + /// A group with no extra indent. + public static Doc group(Doc... docs) { + return new Doc.Group(Indent.Const.ZERO, ImmutableList.copyOf(docs)); + } + + /// A group whose breaks are indented by `plusIndent` relative to the enclosing indent, i.e. + /// Wadler's `group (nest plusIndent ...)`. + public static Doc group(int plusIndent, Doc... docs) { + return new Doc.Group(Indent.Const.make(plusIndent, 1), ImmutableList.copyOf(docs)); + } + + /// A unified break that is written as `flat` when not taken. + public static Doc line(String flat) { + return new Doc.Break(Doc.BreakMode.UNIFIED, flat, Indent.Const.ZERO, Optional.empty()); + } + + /// A unified break that is written as a space when not taken, i.e. Wadler's `line`. + public static Doc line() { + return line(" "); + } + + /// A unified break that is written as nothing when not taken. + public static Doc softline() { + return line(""); + } + + /// A fill break that is written as `flat` when not taken. A group of fill breaks behaves like + /// Wadler's `fill` (§4). + public static Doc fill(String flat) { + return new Doc.Break(Doc.BreakMode.FILL, flat, Indent.Const.ZERO, Optional.empty()); + } + + /// A break that is always taken. + public static Doc hardline() { + return new Doc.Break(Doc.BreakMode.FORCED, "", Indent.Const.ZERO, Optional.empty()); + } + + /// A unified break that records whether it was taken in `tag`. + public static Doc taggedLine(String flat, BreakTag tag) { + return new Doc.Break(Doc.BreakMode.UNIFIED, flat, Indent.Const.ZERO, Optional.of(tag)); + } +} diff --git a/core/src/main/java/com/google/googlejavaformat/layout/LayoutBuilder.java b/core/src/main/java/com/google/googlejavaformat/layout/LayoutBuilder.java new file mode 100644 index 000000000..85d281076 --- /dev/null +++ b/core/src/main/java/com/google/googlejavaformat/layout/LayoutBuilder.java @@ -0,0 +1,629 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; +import com.google.common.base.Predicate; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.DiscreteDomain; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Range; +import com.google.googlejavaformat.CloseOp; +import com.google.googlejavaformat.Doc.Break; +import com.google.googlejavaformat.Doc.FillMode; +import com.google.googlejavaformat.Doc.Space; +import com.google.googlejavaformat.Doc.Token; +import com.google.googlejavaformat.Doc.Token.RealOrImaginary; +import com.google.googlejavaformat.FormatterDiagnostic; +import com.google.googlejavaformat.FormattingError; +import com.google.googlejavaformat.Indent; +import com.google.googlejavaformat.Input; +import com.google.googlejavaformat.Op; +import com.google.googlejavaformat.OpenOp; +import com.google.googlejavaformat.OpsBuilder.BlankLineWanted; +import com.google.googlejavaformat.Output; +import com.google.googlejavaformat.Output.BreakTag; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/// A builder that interfaces with AST visitors and directly produces a declarative [Doc] layout +/// tree. +/// +/// The token handling and comment splicing are adapted from +/// [com.google.googlejavaformat.OpsBuilder], which builds the input to the Oppen-style +/// [com.google.googlejavaformat.Doc] engine. +public final class LayoutBuilder { + + private static final DiscreteDomain INTEGERS = DiscreteDomain.integers(); + private static final Indent.Const ZERO = Indent.Const.ZERO; + + private final Input input; + private final Output output; + private final List events = new ArrayList<>(); + + private int tokenI = 0; + private int inputPosition = Integer.MIN_VALUE; + private int depth = 0; + private int lastPartialFormatBoundary = -1; + + public LayoutBuilder(Input input, Output output) { + this.input = input; + this.output = output; + } + + public Input getInput() { + return input; + } + + public int depth() { + return depth; + } + + public void checkClosed(int previous) { + if (depth != previous) { + throw new FormattingError(diagnostic(String.format("saw %d unclosed ops", depth))); + } + } + + public FormatterDiagnostic diagnostic(String message) { + return input.createDiagnostic(inputPosition, message); + } + + // Adapted from OpsBuilder#sync. + public void sync(int inputPosition) { + if (inputPosition > this.inputPosition) { + ImmutableList tokens = input.getTokens(); + int tokensN = tokens.size(); + this.inputPosition = inputPosition; + if (tokenI < tokensN && inputPosition > tokens.get(tokenI).getTok().getPosition()) { + Input.Token token = tokens.get(tokenI++); + throw new FormattingError( + diagnostic(String.format("did not generate token \"%s\"", token.getTok().getText()))); + } + } + } + + // Adapted from OpsBuilder#drain. + /// Emits any input tokens the AST visitor didn't, as imaginary tokens + /// ([RealOrImaginary#IMAGINARY]) that are reproduced verbatim along with the whitespace and + /// comments around them. In practice this is the end-of-input token and any trailing whitespace. + public void drain() { + int endPosition = input.getText().length() + 1; + if (this.inputPosition < endPosition) { + ImmutableList tokens = input.getTokens(); + int tokensN = tokens.size(); + while (tokenI < tokensN && tokens.get(tokenI).getTok().getPosition() < endPosition) { + Input.Token token = tokens.get(tokenI++); + events.add( + new TokenOp( + token, + RealOrImaginary.IMAGINARY, + ZERO, + /* breakAndIndentTrailingComment= */ Optional.empty())); + } + } + this.inputPosition = endPosition; + checkClosed(0); + } + + public void open(Indent plusIndent) { + depth++; + events.add(new Open(plusIndent)); + } + + public void close() { + depth--; + if (depth < 0) { + throw new AssertionError(); + } + events.add(new Close()); + } + + // Adapted from OpsBuilder#peekToken. + public Optional peekToken() { + return peekToken(0); + } + + public Optional peekToken(int skip) { + ImmutableList tokens = input.getTokens(); + int idx = tokenI + skip; + return idx < tokens.size() + ? Optional.of(tokens.get(idx).getTok().getOriginalText()) + : Optional.empty(); + } + + // Adapted from OpsBuilder#peekTokens. + /// Returns the toks of the upcoming tokens, starting with the current one, for as long as they + /// satisfy `predicate`. The tokens aren't consumed. + /// + /// @param expectedStartPosition the position of the current token, which is checked + public ImmutableList peekTokens( + int expectedStartPosition, Predicate predicate) { + ImmutableList tokens = input.getTokens(); + Preconditions.checkState( + tokens.get(tokenI).getTok().getPosition() == expectedStartPosition, + "Expected the current token to be at position %s, found: %s", + expectedStartPosition, + tokens.get(tokenI)); + return tokens.subList(tokenI, tokens.size()).stream() + .map(Input.Token::getTok) + .takeWhile(predicate) + .collect(toImmutableList()); + } + + // Adapted from OpsBuilder#actualSize. + /// Returns the size of the source text spanned by the tokens starting at `position`, including + /// leading and trailing comments. + public int actualSize(int position, int length) { + Input.Token startToken = input.getPositionTokenMap().get(position); + int start = startToken.getTok().getPosition(); + for (Input.Tok tok : startToken.getToksBefore()) { + if (tok.isComment()) { + start = min(start, tok.getPosition()); + } + } + Input.Token endToken = input.getPositionTokenMap().get(position + length - 1); + int end = endToken.getTok().getPosition() + endToken.getTok().length(); + for (Input.Tok tok : endToken.getToksAfter()) { + if (tok.isComment()) { + end = max(end, tok.getPosition() + tok.length()); + } + } + return end - start; + } + + // Adapted from OpsBuilder#actualStartColumn. + public Integer actualStartColumn(int position) { + Input.Token startToken = input.getPositionTokenMap().get(position); + int start = startToken.getTok().getPosition(); + int line0 = input.getLineNumber(start); + for (Input.Tok tok : startToken.getToksBefore()) { + if (line0 != input.getLineNumber(tok.getPosition())) { + return start; + } + if (tok.isComment()) { + start = min(start, tok.getPosition()); + } + } + return start; + } + + public void guessToken(String token) { + token(token, RealOrImaginary.IMAGINARY, ZERO, Optional.empty()); + } + + // Adapted from OpsBuilder#token. + public void token( + String token, + RealOrImaginary realOrImaginary, + Indent plusIndentCommentsBefore, + Optional breakAndIndentTrailingComment) { + ImmutableList tokens = input.getTokens(); + if (peekToken().filter(token::equals).isPresent()) { + events.add( + new TokenOp( + tokens.get(tokenI++), + RealOrImaginary.REAL, + plusIndentCommentsBefore, + breakAndIndentTrailingComment)); + } else if (realOrImaginary.isReal()) { + // Generated a "bad" token, which doesn't exist on the input. Complain, unless (for example) + // we're guessing at an optional token, in which case it's dropped. + throw new FormattingError( + diagnostic( + String.format( + "expected token: '%s'; generated %s instead", peekToken().orElse(null), token))); + } + } + + public void op(String op) { + int opN = op.length(); + for (int i = 0; i < opN; i++) { + token(op.substring(i, i + 1), RealOrImaginary.REAL, ZERO, Optional.empty()); + } + } + + public void space() { + events.add(new SpaceOp()); + } + + public void breakOp() { + breakOp(FillMode.UNIFIED, "", ZERO); + } + + public void breakOp(Indent plusIndent) { + breakOp(FillMode.UNIFIED, "", plusIndent); + } + + public void breakOp(String flat) { + breakOp(FillMode.UNIFIED, flat, ZERO); + } + + public void breakOp(FillMode fillMode, String flat, Indent plusIndent) { + breakOp(fillMode, flat, plusIndent, Optional.empty()); + } + + public void breakOp( + FillMode fillMode, String flat, Indent plusIndent, Optional optionalTag) { + Doc.BreakMode mode = + switch (fillMode) { + case UNIFIED -> Doc.BreakMode.UNIFIED; + case INDEPENDENT -> Doc.BreakMode.FILL; + case FORCED -> Doc.BreakMode.FORCED; + }; + events.add(new LineBreak(mode, flat, plusIndent, optionalTag)); + } + + public void breakToFill() { + breakOp(FillMode.INDEPENDENT, "", ZERO); + } + + public void breakToFill(String flat) { + breakOp(FillMode.INDEPENDENT, flat, ZERO); + } + + public void forcedBreak() { + breakOp(FillMode.FORCED, "", ZERO); + } + + public void forcedBreak(Indent plusIndent) { + breakOp(FillMode.FORCED, "", plusIndent); + } + + // Adapted from OpsBuilder#markForPartialFormat. + public void markForPartialFormat() { + if (lastPartialFormatBoundary == -1) { + lastPartialFormatBoundary = tokenI; + return; + } + if (tokenI == lastPartialFormatBoundary) { + return; + } + Input.Token start = input.getTokens().get(lastPartialFormatBoundary); + Input.Token end = input.getTokens().get(tokenI - 1); + output.markForPartialFormat(start, end); + lastPartialFormatBoundary = tokenI; + } + + public void blankLineWanted(BlankLineWanted wanted) { + output.blankLine(getI(input.getTokens().get(tokenI)), wanted); + } + + public void add(Op op) { + switch (op) { + case OpenOp openOp -> open(openOp.plusIndent()); + case CloseOp unused -> close(); + case Break b -> breakOp(b.fillMode(), b.flat(), b.plusIndent(), b.optTag()); + case Space unused -> space(); + case Token t -> + token( + t.getToken().getTok().getOriginalText(), + t.realOrImaginary(), + t.getPlusIndentCommentsBefore(), + t.breakAndIndentTrailingComment()); + default -> throw new IllegalArgumentException("unsupported op: " + op); + } + } + + public void addAll(List ops) { + for (Op op : ops) { + add(op); + } + } + + // Adapted from OpsBuilder#getI. + private static int getI(Input.Token token) { + for (Input.Tok tok : token.getToksBefore()) { + if (tok.getIndex() >= 0) { + return tok.getIndex(); + } + } + return token.getTok().getIndex(); + } + + /// Converts the recorded events and comments into a declarative [Doc] tree. + public Doc build() { + markForPartialFormat(); + return buildTree(spliceEvents(commentEvents())); + } + + // Adapted from OpsBuilder#build. Token events can have associated non-tokens, including + // comments, which we need to insert. They can also cause line breaks, so we insert them before + // or after the current level, when possible. + /// Returns the events for the non-tokens (comments and whitespace) around each token, keyed by + /// the index in `events` that they should be inserted before. + private ListMultimap commentEvents() { + ListMultimap tokEvents = ArrayListMultimap.create(); + for (int i = 0; i < events.size(); i++) { + if (events.get(i) instanceof TokenOp tokenOp) { + // Insert toksBefore before any groups the token opens, and toksAfter after any groups it + // closes. + List before = tokEvents.get(firstOpenBefore(i)); + List after = tokEvents.get(lastCloseAfter(i) + 1); + if (tokenOp.realOrImaginary().isReal()) { + addToksBefore(tokenOp, before); + addToksAfter(tokenOp, after); + } else { + addImaginaryToks(tokenOp.token(), before, after); + } + } + } + return tokEvents; + } + + /// Returns the index of the first of the `Open` events immediately preceding `events[i]`, or `i` + /// if there are none. + private int firstOpenBefore(int i) { + int j = i; + while (j > 0 && events.get(j - 1) instanceof Open) { + j--; + } + return j; + } + + /// Returns the index of the last of the `Close` events immediately following `events[i]`, or `i` + /// if there are none. + private int lastCloseAfter(int i) { + int k = i; + while (k + 1 < events.size() && events.get(k + 1) instanceof Close) { + k++; + } + return k; + } + + /// Adds the comments before a real token to `out`, with the breaks and spaces around them. + private void addToksBefore(TokenOp tokenOp, List out) { + Input.Token token = tokenOp.token(); + int newlines = 0; // Count of newlines in a row. + boolean space = false; // Do we need an extra space after a previous "/*" comment? + boolean lastWasComment = false; // Was the last thing we output a comment? + boolean allowBlankAfterLastComment = false; + for (Input.Tok tokBefore : token.getToksBefore()) { + if (tokBefore.isNewline()) { + newlines++; + } else if (tokBefore.isComment()) { + out.add( + new LineBreak( + tokBefore.isSlashSlashComment() ? Doc.BreakMode.FORCED : Doc.BreakMode.UNIFIED, + "", + tokenOp.plusIndentCommentsBefore(), + Optional.empty())); + out.add(new TokOp(tokBefore)); + if (tokBefore.isSlashSlashComment()) { + out.add(forcedLineBreak()); + } + space = tokBefore.isSlashStarComment(); + newlines = 0; + lastWasComment = true; + if (tokBefore.isJavadocComment()) { + out.add(forcedLineBreak()); + } + allowBlankAfterLastComment = + tokBefore.isSlashSlashComment() + || (tokBefore.isSlashStarComment() && !tokBefore.isJavadocComment()); + } + } + if (allowBlankAfterLastComment && newlines > 1) { + // Force a blank line after two newlines in a row following a line or block comment. + output.blankLine(token.getTok().getIndex(), BlankLineWanted.YES); + } + if (lastWasComment && newlines > 0) { + out.add(forcedLineBreak()); + } else if (space) { + out.add(new SpaceOp()); + } + } + + /// Adds the comments after a real token to `out`, with the breaks and spaces around them. + private static void addToksAfter(TokenOp tokenOp, List out) { + for (Input.Tok tokAfter : tokenOp.token().getToksAfter()) { + if (!tokAfter.isComment()) { + continue; + } + boolean breakAfter = + tokAfter.isJavadocComment() + || (tokAfter.isSlashStarComment() + && tokenOp.breakAndIndentTrailingComment().isPresent()); + out.add( + breakAfter + ? forcedLineBreak(tokenOp.breakAndIndentTrailingComment().orElse(ZERO)) + : new SpaceOp()); + out.add(new TokOp(tokAfter)); + // OpsBuilder adds two forced breaks after a trailing `///` comment (which is both a line + // comment and a doc comment); one is enough. + if (breakAfter || tokAfter.isSlashSlashComment()) { + out.add(forcedLineBreak()); + } + } + } + + // Adapted from OpsBuilder#build. This input token was not generated for output (in practice, + // it's the end-of-input token from drain). As no whitespace or comments were generated for it, + // copy all of its non-tokens literally, even spaces and newlines. + private static void addImaginaryToks(Input.Token token, List before, List after) { + int newlines = 0; + boolean lastWasComment = false; + for (Input.Tok tokBefore : token.getToksBefore()) { + if (tokBefore.isNewline()) { + newlines++; + } else if (tokBefore.isComment()) { + newlines = 0; + lastWasComment = true; + } + if (lastWasComment && newlines > 0) { + before.add(forcedLineBreak()); + } + before.add(new TokOp(tokBefore)); + } + for (Input.Tok tokAfter : token.getToksAfter()) { + after.add(new TokOp(tokAfter)); + } + } + + // Adapted from OpsBuilder#build. + /// Returns `events` with the events from `tokEvents` spliced in, suppressing spaces after forced + /// breaks. + private List spliceEvents(ListMultimap tokEvents) { + List result = new ArrayList<>(); + boolean afterForcedBreak = false; + for (int i = 0; i < events.size(); i++) { + afterForcedBreak = addSuppressingSpaces(tokEvents.get(i), afterForcedBreak, result); + Event ev = events.get(i); + if (afterForcedBreak && isSpaceLike(ev)) { + continue; + } + result.add(ev); + // An `Open` doesn't output anything, so it doesn't end the run that follows a forced break. + if (!(ev instanceof Open)) { + afterForcedBreak = isForcedBreak(ev); + } + } + var unused = addSuppressingSpaces(tokEvents.get(events.size()), afterForcedBreak, result); + return result; + } + + /// Appends `toAdd` to `out`, dropping any space that directly follows a forced break, and returns + /// whether the last event appended was a forced break. + /// + /// @param afterForcedBreak whether the last event already in `out` is a forced break + private static boolean addSuppressingSpaces( + List toAdd, boolean afterForcedBreak, List out) { + for (Event ev : toAdd) { + if (!(afterForcedBreak && ev instanceof SpaceOp)) { + out.add(ev); + afterForcedBreak = isForcedBreak(ev); + } + } + return afterForcedBreak; + } + + /// Returns true for an event that's redundant directly after a forced break, which has already + /// ended the line: a space, or a break with no extra indent that's a space when not taken. + private static boolean isSpaceLike(Event ev) { + return switch (ev) { + case SpaceOp unused -> true; + case LineBreak lb -> lb.plusIndent().eval() == 0 && lb.flat().equals(" "); + default -> false; + }; + } + + /// Builds the tree of groups from `events`. + /// + /// Following `DocBuilder`, non-break docs are appended to the group that contains the most recent + /// break, rather than to the innermost open group. This moves trailing text (e.g. the `;` after a + /// closed group) into the preceding group, so it's accounted for when deciding whether that group + /// fits on the line. + private static Doc buildTree(List events) { + LevelBuilder base = new LevelBuilder(ZERO); + ArrayDeque stack = new ArrayDeque<>(); + stack.addLast(base); + LevelBuilder appendLevel = base; + for (Event ev : events) { + switch (ev) { + case Open open -> stack.addLast(new LevelBuilder(open.plusIndent())); + case Close close -> { + LevelBuilder top = stack.removeLast(); + stack.getLast().add(top); + } + case LineBreak lb -> { + appendLevel = stack.getLast(); + appendLevel.add(new Doc.Break(lb.mode(), lb.flat(), lb.plusIndent(), lb.tag())); + } + case TokenOp tokenOp -> { + Input.Tok tok = tokenOp.token().getTok(); + appendLevel.add( + new Doc.Text( + tok.getOriginalText(), Range.singleton(tok.getIndex()).canonical(INTEGERS))); + } + case TokOp tokOp -> appendLevel.add(new Doc.Tok(tokOp.tok())); + case SpaceOp spaceOp -> appendLevel.add(Doc.Space.INSTANCE); + } + } + return base.build(); + } + + private static boolean isForcedBreak(Event ev) { + return ev instanceof LineBreak lb && lb.mode() == Doc.BreakMode.FORCED; + } + + private static LineBreak forcedLineBreak() { + return forcedLineBreak(ZERO); + } + + private static LineBreak forcedLineBreak(Indent plusIndent) { + return new LineBreak(Doc.BreakMode.FORCED, "", plusIndent, Optional.empty()); + } + + /// A mutable group under construction; see [#buildTree]. + private static final class LevelBuilder { + final Indent plusIndent; + final List items = new ArrayList<>(); + + LevelBuilder(Indent plusIndent) { + this.plusIndent = plusIndent; + } + + void add(Object item) { + items.add(item); + } + + Doc.Group build() { + ImmutableList.Builder docs = ImmutableList.builder(); + for (Object item : items) { + docs.add(item instanceof LevelBuilder lb ? lb.build() : (Doc) item); + } + return new Doc.Group(plusIndent, docs.build()); + } + } + + private sealed interface Event permits Open, Close, LineBreak, TokenOp, TokOp, SpaceOp {} + + private record Open(Indent plusIndent) implements Event {} + + private record Close() implements Event {} + + private record LineBreak( + Doc.BreakMode mode, String flat, Indent plusIndent, Optional tag) + implements Event {} + + private record TokenOp( + Input.Token token, + RealOrImaginary realOrImaginary, + Indent plusIndentCommentsBefore, + Optional breakAndIndentTrailingComment) + implements Event {} + + private record TokOp(Input.Tok tok) implements Event {} + + private record SpaceOp() implements Event {} + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("input", input) + .add("output", output) + .add("tokenI", tokenI) + .add("depth", depth) + .toString(); + } +} diff --git a/core/src/main/java/com/google/googlejavaformat/layout/LayoutSolver.java b/core/src/main/java/com/google/googlejavaformat/layout/LayoutSolver.java new file mode 100644 index 000000000..0ee8a8d9a --- /dev/null +++ b/core/src/main/java/com/google/googlejavaformat/layout/LayoutSolver.java @@ -0,0 +1,210 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.Math.max; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Range; +import com.google.googlejavaformat.CommentsHelper; +import com.google.googlejavaformat.Indent; +import com.google.googlejavaformat.Newlines; +import com.google.googlejavaformat.Output; +import java.util.ArrayList; +import java.util.List; + +/// Lays out a [Doc]. +/// +/// Layout is greedy and single-pass, like Wadler's `be`/`better` ([A prettier +/// printer](https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf), §2-3): each group +/// is laid out flat if it fits, and otherwise broken, without revisiting earlier decisions. The fit +/// test is local to the group (see [Doc]), so every decision looks at most `maxWidth` columns ahead +/// and layout takes time linear in the size of the document. +/// +/// Layout happens in two phases. The first phase makes every line-breaking decision in document +/// order and records the resulting output operations; it also records whether each +/// [Output.BreakTag] was taken. The second phase replays the operations to the output. Separating +/// the phases lets output decisions that depend on break tags (e.g. conditional blank lines) see +/// the final state of every tag. +public final class LayoutSolver { + + private LayoutSolver() {} + + /// Formats `doc` to a string. Comments are written verbatim. + public static String format(Doc doc, int maxWidth) { + StringBuilder sb = new StringBuilder(); + for (Action action : solve(doc, maxWidth, /* commentsHelper= */ null)) { + switch (action) { + case Append a -> sb.append(a.text()); + case Newline n -> sb.append('\n'); + case IndentTo i -> sb.repeat(" ", i.spaces()); + } + } + return sb.toString(); + } + + /// Formats `doc` to `output`, reformatting comments with `commentsHelper`. + public static void format(Doc doc, int maxWidth, Output output, CommentsHelper commentsHelper) { + for (Action action : solve(doc, maxWidth, commentsHelper)) { + switch (action) { + case Append a -> output.append(a.text(), a.range()); + case Newline n -> output.append("\n", Doc.EMPTY_RANGE); + case IndentTo i -> output.indent(i.spaces()); + } + } + } + + private sealed interface Action {} + + private record Append(String text, Range range) implements Action {} + + private record Newline() implements Action {} + + private record IndentTo(int spaces) implements Action {} + + /// Layout state. + /// + /// @param indent the indent for breaks in the current group + /// @param column the current output column + private record State(int indent, int column) { + State withColumn(int column) { + return new State(indent, column); + } + } + + private static List solve(Doc doc, int maxWidth, CommentsHelper commentsHelper) { + // Widths saturate at MAX_LINE_WIDTH, which is also how forced breaks are represented, so a + // document whose width is MAX_LINE_WIDTH must never fit. + checkArgument( + maxWidth < Doc.MAX_LINE_WIDTH, + "maxWidth must be less than %s: %s", + Doc.MAX_LINE_WIDTH, + maxWidth); + Doc.Group root = + doc instanceof Doc.Group g ? g : new Doc.Group(Indent.Const.ZERO, ImmutableList.of(doc)); + Solver solver = new Solver(maxWidth, commentsHelper); + var unused = solver.group(root, new State(0, 0)); + return solver.actions; + } + + private static final class Solver { + private final int maxWidth; + private final CommentsHelper commentsHelper; + private final List actions = new ArrayList<>(); + + Solver(int maxWidth, CommentsHelper commentsHelper) { + this.maxWidth = maxWidth; + this.commentsHelper = commentsHelper; + } + + /// A group that fits is laid out flat (Wadler's `flatten`); otherwise it's split at its direct + /// breaks. This is Wadler's `group x = flatten x <|> x`, choosing the flat alternative whenever + /// it fits. + State group(Doc.Group group, State state) { + int width = group.width(); + if (state.column() + width <= maxWidth) { + actions.add(new Append(group.flat(), group.range())); + return state.withColumn(state.column() + width); + } + State broken = + broken(group, new State(state.indent() + group.plusIndent().eval(), state.column())); + return state.withColumn(broken.column()); + } + + private State broken(Doc.Group group, State state) { + List segment = new ArrayList<>(); + Doc.Break before = null; + boolean previousFits = true; + for (Doc doc : group.docs()) { + if (doc instanceof Doc.Break b) { + SegmentResult result = segment(before, segment, previousFits, state); + state = result.state(); + previousFits = result.fits(); + before = b; + segment = new ArrayList<>(); + } else { + segment.add(doc); + } + } + return segment(before, segment, previousFits, state).state(); + } + + /// The result of laying out a segment. + /// + /// @param state the state after the segment + /// @param fits whether the segment fit on the line it started on + private record SegmentResult(State state, boolean fits) {} + + /// Lays out a break and the segment following it. Unified and forced breaks are taken whenever + /// their group is broken; a fill break is taken if the segment doesn't fit, or if the previous + /// segment didn't fit. Fill breaks are a segment-at-a-time version of Wadler's `fill` (§4), + /// which places a separator flat when the next item fits and breaks otherwise. + private SegmentResult segment( + Doc.Break before, List segment, boolean previousFits, State state) { + int segmentWidth = Doc.Group.totalWidth(segment); + if (before != null) { + boolean taken = + switch (before.mode()) { + case UNIFIED, FORCED -> true; + case FILL -> + !previousFits || state.column() + before.width() + segmentWidth > maxWidth; + }; + state = breakOp(before, taken, state); + } + boolean fits = state.column() + segmentWidth <= maxWidth; + for (Doc doc : segment) { + state = leaf(doc, state); + } + return new SegmentResult(state, fits); + } + + private State breakOp(Doc.Break b, boolean taken, State state) { + b.tag().ifPresent(t -> t.recordBroken(taken)); + if (taken) { + int indent = max(state.indent() + b.plusIndent().eval(), 0); + actions.add(new Newline()); + actions.add(new IndentTo(indent)); + return state.withColumn(indent); + } + actions.add(new Append(b.flatText(), Doc.EMPTY_RANGE)); + return state.withColumn(state.column() + b.flatText().length()); + } + + private State leaf(Doc doc, State state) { + return switch (doc) { + case Doc.Group g -> group(g, state); + case Doc.Text t -> { + actions.add(new Append(t.text(), t.range())); + yield state.withColumn(state.column() + t.width()); + } + case Doc.Space s -> { + actions.add(new Append(" ", Doc.EMPTY_RANGE)); + yield state.withColumn(state.column() + 1); + } + case Doc.Tok c -> { + String text = + commentsHelper != null + ? commentsHelper.rewrite(c.tok(), maxWidth, state.column()) + : c.flat(); + actions.add(new Append(text, c.range())); + int lastLineLength = text.length() - Newlines.lastLineStart(text); + yield state.withColumn(state.column() + lastLineLength); + } + case Doc.Break b -> throw new AssertionError(b); + }; + } + } +} diff --git a/core/src/test/java/com/google/googlejavaformat/NewlinesTest.java b/core/src/test/java/com/google/googlejavaformat/NewlinesTest.java index 4b7dab78d..e56191f37 100644 --- a/core/src/test/java/com/google/googlejavaformat/NewlinesTest.java +++ b/core/src/test/java/com/google/googlejavaformat/NewlinesTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.fail; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterators; import java.util.Iterator; import java.util.NoSuchElementException; import org.junit.Test; @@ -61,6 +62,29 @@ public void lines() { .containsExactly("foo\r\n", "bar"); } + @Test + public void lastLineStart() { + assertThat(Newlines.lastLineStart("")).isEqualTo(0); + assertThat(Newlines.lastLineStart("foo")).isEqualTo(0); + assertThat(Newlines.lastLineStart("foo\nbar")).isEqualTo(4); + assertThat(Newlines.lastLineStart("foo\rbar")).isEqualTo(4); + assertThat(Newlines.lastLineStart("foo\r\nbar")).isEqualTo(5); + assertThat(Newlines.lastLineStart("foo\n")).isEqualTo(4); + assertThat(Newlines.lastLineStart("foo\r\n")).isEqualTo(5); + assertThat(Newlines.lastLineStart("a\n\r\nb")).isEqualTo(4); + assertThat(Newlines.lastLineStart("a\r\rb")).isEqualTo(3); + } + + @Test + public void lastLineStart_matchesLineOffsetIterator() { + for (String input : + ImmutableList.of( + "", "foo", "foo\nbar\n", "foo\rbar", "foo\r\nbar\r\n", "\n", "\r\n\r", "a\n\r\nb")) { + assertThat(Newlines.lastLineStart(input)) + .isEqualTo(Iterators.getLast(Newlines.lineOffsetIterator(input))); + } + } + @Test public void terminalOffset() { Iterator it = Newlines.lineOffsetIterator("foo\nbar\n"); diff --git a/core/src/test/java/com/google/googlejavaformat/java/LayoutBuilderTest.java b/core/src/test/java/com/google/googlejavaformat/java/LayoutBuilderTest.java new file mode 100644 index 000000000..d8911852b --- /dev/null +++ b/core/src/test/java/com/google/googlejavaformat/java/LayoutBuilderTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.java; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.googlejavaformat.Doc.Token.RealOrImaginary; +import com.google.googlejavaformat.FormattingError; +import com.google.googlejavaformat.Indent; +import com.google.googlejavaformat.layout.LayoutBuilder; +import com.sun.tools.javac.util.Context; +import java.util.ArrayList; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/// Tests for [LayoutBuilder]. +/// +/// These live in the `java` package so they can use [JavaInput], which is package-private. +@RunWith(JUnit4.class) +public class LayoutBuilderTest { + + @Test + public void sync_afterToken() throws Exception { + LayoutBuilder builder = newBuilder("class T {}\n"); + builder.sync(0); + builder.token("class", RealOrImaginary.REAL, Indent.Const.ZERO, Optional.empty()); + builder.sync(6); + } + + @Test + public void sync_tokenNotGenerated() throws Exception { + LayoutBuilder builder = newBuilder("class T {}\n"); + builder.sync(0); + FormattingError e = assertThrows(FormattingError.class, () -> builder.sync(6)); + assertThat(e).hasMessageThat().isEqualTo("1:7: error: did not generate token \"class\"\n"); + } + + private static LayoutBuilder newBuilder(String text) throws FormatterException { + JavaInput input = new JavaInput(text); + // Diagnostics use the compilation unit's line map. + input.setCompilationUnit( + Trees.parse(new Context(), new ArrayList<>(), /* allowStringFolding= */ false, text)); + // sync doesn't produce output, so no Output is needed. + return new LayoutBuilder(input, /* output= */ null); + } +} diff --git a/core/src/test/java/com/google/googlejavaformat/layout/DocTest.java b/core/src/test/java/com/google/googlejavaformat/layout/DocTest.java new file mode 100644 index 000000000..3b31e69c7 --- /dev/null +++ b/core/src/test/java/com/google/googlejavaformat/layout/DocTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.googlejavaformat.layout.Docs.group; +import static com.google.googlejavaformat.layout.Docs.hardline; +import static com.google.googlejavaformat.layout.Docs.line; +import static com.google.googlejavaformat.layout.Docs.space; +import static com.google.googlejavaformat.layout.Docs.text; + +import com.google.common.collect.Range; +import com.google.googlejavaformat.Input; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/// Unit tests for [Doc]. +@RunWith(JUnit4.class) +public class DocTest { + + /// A minimal [Input.Tok] with the given text. + private record FakeTok(int index, String text) implements Input.Tok { + @Override + public int getIndex() { + return index; + } + + @Override + public int getPosition() { + return 0; + } + + @Override + public int getColumn() { + return 0; + } + + @Override + public String getText() { + return text; + } + + @Override + public String getOriginalText() { + return text; + } + + @Override + public int length() { + return text.length(); + } + + @Override + public boolean isNewline() { + return text.equals("\n"); + } + + @Override + public boolean isSlashSlashComment() { + return text.startsWith("//"); + } + + @Override + public boolean isSlashStarComment() { + return text.startsWith("/*"); + } + + @Override + public boolean isJavadocComment() { + return text.startsWith("/**"); + } + + @Override + public boolean isComment() { + return isSlashSlashComment() || isSlashStarComment(); + } + } + + private static Doc.Tok tok(String text) { + return new Doc.Tok(new FakeTok(0, text)); + } + + @Test + public void textWidth() { + assertThat(text("hello").width()).isEqualTo(5); + assertThat(text("a\nb").width()).isEqualTo(Doc.MAX_LINE_WIDTH); + } + + @Test + public void commentWidth() { + assertThat(tok("// foo").width()).isEqualTo(6); + assertThat(tok("/* foo */").width()).isEqualTo(9); + } + + @Test + public void multiLineCommentWidthIsFirstLine() { + // The width is the offset of the second line, i.e. the first line including its terminator. + assertThat(tok("/* foo\n * bar\n */").width()).isEqualTo("/* foo\n".length()); + } + + @Test + public void slashSlashCommentFlatAddsSpace() { + Doc.Tok tok = tok("//foo"); + assertThat(tok.flat()).isEqualTo("// foo"); + assertThat(tok.width()).isEqualTo(6); + } + + @Test + public void whitespaceTokWidth() { + assertThat(tok(" ").width()).isEqualTo(2); + assertThat(tok(" ").flat()).isEqualTo(" "); + } + + @Test + public void newlineTokNeverFits() { + assertThat(tok("\n").width()).isEqualTo(Doc.MAX_LINE_WIDTH); + assertThat(tok(" \n ").width()).isEqualTo(Doc.MAX_LINE_WIDTH); + } + + @Test + public void tokRange() { + assertThat(new Doc.Tok(new FakeTok(7, "// x")).range()).isEqualTo(Range.closedOpen(7, 8)); + } + + @Test + public void spaceAndBreakWidth() { + assertThat(space().width()).isEqualTo(1); + assertThat(space().flat()).isEqualTo(" "); + assertThat(line().width()).isEqualTo(1); + assertThat(line("").width()).isEqualTo(0); + assertThat(hardline().width()).isEqualTo(Doc.MAX_LINE_WIDTH); + } + + @Test + public void groupWidthAndFlat() { + Doc doc = group(text("a"), line(), group(text("b"), space(), text("c"))); + assertThat(doc.width()).isEqualTo(5); + assertThat(doc.flat()).isEqualTo("a b c"); + } + + @Test + public void groupWithForcedBreakNeverFits() { + Doc doc = group(text("a"), hardline(), text("b")); + assertThat(doc.width()).isEqualTo(Doc.MAX_LINE_WIDTH); + } + + @Test + public void groupWidthSaturates() { + Doc doc = group(text("a".repeat(Doc.MAX_LINE_WIDTH)), text("b")); + assertThat(doc.width()).isEqualTo(Doc.MAX_LINE_WIDTH); + } + + @Test + public void groupRangeSpansChildren() { + Doc doc = + group( + text("("), + new Doc.Text("a", Range.closedOpen(3, 4)), + line(), + new Doc.Text("b", Range.closedOpen(6, 7)), + text(")")); + assertThat(doc.range()).isEqualTo(Range.closedOpen(3, 7)); + assertThat(group(text("a"), line(), text("b")).range()).isEqualTo(Doc.EMPTY_RANGE); + } +} diff --git a/core/src/test/java/com/google/googlejavaformat/layout/LayoutSolverTest.java b/core/src/test/java/com/google/googlejavaformat/layout/LayoutSolverTest.java new file mode 100644 index 000000000..9b082e5ac --- /dev/null +++ b/core/src/test/java/com/google/googlejavaformat/layout/LayoutSolverTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.googlejavaformat.layout; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.googlejavaformat.layout.Docs.fill; +import static com.google.googlejavaformat.layout.Docs.group; +import static com.google.googlejavaformat.layout.Docs.hardline; +import static com.google.googlejavaformat.layout.Docs.line; +import static com.google.googlejavaformat.layout.Docs.softline; +import static com.google.googlejavaformat.layout.Docs.space; +import static com.google.googlejavaformat.layout.Docs.taggedLine; +import static com.google.googlejavaformat.layout.Docs.text; +import static org.junit.Assert.assertThrows; + +import com.google.googlejavaformat.Output.BreakTag; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/// Unit tests for the Layout IR and [LayoutSolver]. +@RunWith(JUnit4.class) +public class LayoutSolverTest { + + private static Doc call() { + return group(2, text("foo("), softline(), text("a,"), line(), text("b)")); + } + + @Test + public void singleText() { + assertThat(LayoutSolver.format(text("hello"), 80)).isEqualTo("hello"); + } + + @Test + public void groupFitsFlat() { + assertThat(LayoutSolver.format(call(), 20)).isEqualTo("foo(a, b)"); + } + + @Test + public void groupBreaksWithIndent() { + assertThat(LayoutSolver.format(call(), 5)).isEqualTo("foo(\n a,\n b)"); + } + + @Test + public void unifiedBreaksAllBreakTogether() { + // "b c" would fit on the second line, but unified breaks in a broken group all break. + Doc doc = group(text("a"), line(), text("b"), line(), text("c")); + assertThat(LayoutSolver.format(doc, 3)).isEqualTo("a\nb\nc"); + } + + @Test + public void fillBreaksMeasureWholeSegment() { + Doc doc = + group( + text("one,"), + fill(" "), + text("two,"), + fill(" "), + text("three,"), + fill(" "), + text("four")); + assertThat(LayoutSolver.format(doc, 11)).isEqualTo("one, two,\nthree, four"); + assertThat(LayoutSolver.format(doc, 10)).isEqualTo("one, two,\nthree,\nfour"); + } + + @Test + public void fillBreakAfterSegmentThatDoesNotFit() { + // After breaking before the inner group, the group still doesn't fit on its own line, so the + // following fill break is taken too even though "y" would fit after "b)". + Doc doc = + group( + text("x"), + fill(" "), + group(4, text("ffff("), softline(), text("aaaa,"), line(), text("b)")), + fill(" "), + text("y")); + assertThat(LayoutSolver.format(doc, 10)).isEqualTo("x\nffff(\n aaaa,\n b)\ny"); + } + + @Test + public void nestedGroupStaysFlatInsideBrokenGroup() { + Doc doc = group(text("xxxx"), line(), call()); + assertThat(LayoutSolver.format(doc, 10)).isEqualTo("xxxx\nfoo(a, b)"); + } + + @Test + public void hardlineForcesEnclosingBreaks() { + Doc doc = group(text("a"), line(), text("b"), hardline(), text("c")); + assertThat(LayoutSolver.format(doc, 100)).isEqualTo("a\nb\nc"); + } + + @Test + public void maxWidthTooLarge() { + // Widths saturate at Doc.MAX_LINE_WIDTH, so a larger maxWidth would lay out forced breaks flat. + Doc doc = group(text("a"), hardline(), text("b")); + assertThat(LayoutSolver.format(doc, Doc.MAX_LINE_WIDTH - 1)).isEqualTo("a\nb"); + assertThrows( + IllegalArgumentException.class, () -> LayoutSolver.format(doc, Doc.MAX_LINE_WIDTH)); + } + + @Test + public void breakTagRecordsFlat() { + BreakTag tag = new BreakTag(); + Doc doc = group(text("a"), taggedLine(" ", tag), text("b")); + assertThat(LayoutSolver.format(doc, 10)).isEqualTo("a b"); + // Breaks inside a group that fits are not visited. + assertThat(tag.wasBreakTaken()).isFalse(); + } + + @Test + public void breakTagRecordsBroken() { + BreakTag tag = new BreakTag(); + Doc doc = group(text("a"), taggedLine(" ", tag), text("b")); + assertThat(LayoutSolver.format(doc, 2)).isEqualTo("a\nb"); + assertThat(tag.wasBreakTaken()).isTrue(); + } + + @Test + public void spacesDoNotBreak() { + Doc doc = group(text("a"), space(), text("b"), space(), text("c")); + assertThat(LayoutSolver.format(doc, 3)).isEqualTo("a b c"); + } +}