blob: 429a958a8e5fedf4551bdd87f6622f91e8d31e4f [file] [log] [blame]
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import '../ast_extensions.dart';
import '../comment_type.dart';
import '../piece/list.dart';
import '../piece/piece.dart';
import 'comment_writer.dart';
import 'piece_factory.dart';
/// Incrementally builds a [ListPiece], handling commas, comments, and
/// newlines that may appear before, between, or after its contents.
///
/// Users of this should call [leftBracket()] first, passing in the opening
/// delimiter token. Then call [add()] for each [AstNode] that is inside the
/// delimiters. The [rightBracket()] with the closing delimiter and finally
/// [build()] to get the resulting [ListPiece].
class DelimitedListBuilder {
final PieceFactory _visitor;
/// The opening bracket before the elements, if any.
Piece? _leftBracket;
/// The list of elements in the list.
final List<ListElement> _elements = [];
/// The element that should have a blank line preserved between them and the
/// next piece.
final Set<ListElement> _blanksAfter = {};
/// The closing bracket after the elements, if any.
Piece? _rightBracket;
bool _mustSplit = false;
final ListStyle _style;
/// The comments that should appear before the next element.
final List<Piece> _leadingComments = [];
/// The list of comments following the most recently written element before
/// any comma following the element.
CommentSequence _commentsBeforeComma = CommentSequence.empty;
/// Creates a new [DelimitedListBuilder] for an argument list, collection
/// literal, etc.
DelimitedListBuilder(this._visitor, [this._style = const ListStyle()]);
/// Creates the final [ListPiece] out of the added brackets, delimiters,
/// elements, and style.
ListPiece build() {
_setBlockElementFormatting();
var piece =
ListPiece(_leftBracket, _elements, _blanksAfter, _rightBracket, _style);
if (_mustSplit) piece.pin(State.split);
return piece;
}
/// Adds the opening [bracket] to the built list.
///
/// If [delimiter] is given, it is a second bracket occurring immediately
/// after [bracket]. This is used for parameter lists where all parameters
/// are optional or named, as in:
///
/// function([parameter]);
///
/// Here, [bracket] will be `(` and [delimiter] will be `[`.
void leftBracket(Token bracket, {Piece? preceding, Token? delimiter}) {
_leftBracket = _visitor.buildPiece((b) {
if (preceding != null) {
b.add(preceding);
b.space();
}
b.token(bracket);
b.token(delimiter);
});
}
/// Adds the closing [bracket] to the built list along with any comments that
/// precede it.
///
/// If [delimiter] is given, it is a second bracket occurring immediately
/// after [bracket]. This is used for parameter lists with optional or named
/// parameters, like:
///
/// function(mandatory, {named});
///
/// Here, [bracket] will be `)` and [delimiter] will be `}`.
///
/// If [semicolon] is given, it is the optional `;` in an enum declaration
/// after the enum constants when there are no subsequent members. Comments
/// before the `;` are kept, but the `;` itself is discarded.
void rightBracket(Token bracket, {Token? delimiter, Token? semicolon}) {
// Handle comments after the last element.
var commentsBefore = _visitor.comments.takeCommentsBefore(bracket);
// Merge the comments before the delimiter (if there is one) and the
// bracket. If there is a delimiter, this will move comments between it and
// the bracket to before the delimiter, as in:
//
// // Before:
// f([parameter] /* comment */) {}
//
// // After:
// f([parameter /* comment */]) {}
if (delimiter != null) {
commentsBefore = _visitor.comments
.takeCommentsBefore(delimiter)
.concatenate(commentsBefore);
}
if (semicolon != null) {
commentsBefore = _visitor.comments
.takeCommentsBefore(semicolon)
.concatenate(commentsBefore);
}
_addComments(commentsBefore, hasElementAfter: false);
_rightBracket = _visitor.buildPiece((b) {
b.token(delimiter);
b.token(bracket);
});
}
/// Adds [piece] to the built list.
///
/// Use this when the piece is composed of more than one [AstNode] or [Token]
/// and [visit()] can't be used. When calling this, make sure to call
/// [addCommentsBefore()] for the first token in the [piece].
///
/// Assumes there is no comma after this piece.
void add(Piece piece, [BlockFormat format = BlockFormat.none]) {
_elements.add(ListElement(_leadingComments, piece, format));
_leadingComments.clear();
_commentsBeforeComma = CommentSequence.empty;
}
/// Writes any comments appearing before [token] to the list.
void addCommentsBefore(Token token) {
// Handle comments between the preceding element and this one.
var commentsBeforeElement = _visitor.comments.takeCommentsBefore(token);
_addComments(commentsBeforeElement, hasElementAfter: true);
}
/// Adds [element] to the built list.
void visit(AstNode element) {
// Handle comments between the preceding element and this one.
addCommentsBefore(element.firstNonCommentToken);
// See if it's an expression that supports block formatting.
var format = switch (element) {
AdjacentStrings(indentStrings: true) =>
BlockFormat.indentedAdjacentStrings,
AdjacentStrings() => BlockFormat.unindentedAdjacentStrings,
FunctionExpression() when element.canBlockSplit => BlockFormat.function,
Expression() when element.canBlockSplit => BlockFormat.block,
DartPattern() when element.canBlockSplit => BlockFormat.block,
_ => BlockFormat.none,
};
// Traverse the element itself.
add(_visitor.nodePiece(element), format);
var nextToken = element.endToken.next!;
if (nextToken.lexeme == ',') {
_commentsBeforeComma = _visitor.comments.takeCommentsBefore(nextToken);
}
}
/// Inserts an inner left delimiter between two elements.
///
/// This is used for parameter lists when there are both mandatory and
/// optional or named parameters to insert the `[` or `{`, respectively.
///
/// This should not be used if [delimiter] appears before all elements. In
/// that case, pass it to [leftBracket].
void leftDelimiter(Token delimiter) {
assert(_elements.isNotEmpty);
// Preserve any comments before the delimiter. Treat them as occurring
// before the previous element's comma. This means that:
//
// function(p1, /* comment */ [p1]);
//
// Will be formatted as:
//
// function(p1 /* comment */, [p1]);
//
// (In practice, it's such an unusual place for a comment that it doesn't
// matter that much where it goes and this seems to be simple and
// reasonable looking.)
_commentsBeforeComma = _commentsBeforeComma
.concatenate(_visitor.comments.takeCommentsBefore(delimiter));
// Attach the delimiter to the previous element.
_elements.last.setDelimiter(delimiter.lexeme);
}
/// Adds [comments] to the list.
///
/// If [hasElementAfter] is `true` then another element will be written after
/// these comments. Otherwise, we are at the comments after the last element
/// before the closing delimiter.
void _addComments(CommentSequence comments, {required bool hasElementAfter}) {
// Early out if there's nothing to do.
if (_commentsBeforeComma.isEmpty && comments.isEmpty) return;
if (_commentsBeforeComma.requiresNewline || comments.requiresNewline) {
_mustSplit = true;
}
// Figure out which comments are anchored to the preceding element, which
// are freestanding, and which are attached to the next element.
var (
inline: inlineComments,
hanging: hangingComments,
separate: separateComments,
leading: leadingComments
) = _splitCommaComments(comments, hasElementAfter: hasElementAfter);
// Add any hanging inline block comments to the previous element before the
// subsequent ",".
for (var comment in inlineComments) {
var commentPiece = _visitor.pieces.writeComment(comment);
_elements.last.addComment(commentPiece, beforeDelimiter: true);
}
// Add any remaining hanging line comments to the previous element after
// the ",".
if (hangingComments.isNotEmpty) {
for (var comment in hangingComments) {
var commentPiece = _visitor.pieces.writeComment(comment);
_elements.last.addComment(commentPiece);
}
}
// Comments that are neither hanging nor leading are treated like their own
// elements.
for (var i = 0; i < separateComments.length; i++) {
var comment = separateComments[i];
if (separateComments.linesBefore(i) > 1 && _elements.isNotEmpty) {
_blanksAfter.add(_elements.last);
}
var commentPiece = _visitor.pieces.writeComment(comment);
_elements.add(ListElement.comment(commentPiece));
}
// Leading comments are written before the next element.
for (var comment in leadingComments) {
var commentPiece = _visitor.pieces.writeComment(comment);
_leadingComments.add(commentPiece);
}
}
/// Given the comments that followed the previous element before its comma
/// and [commentsBeforeElement], the comments before the element we are about
/// to write (and after the preceding element's comma), splits them into to
/// four comment sequences:
///
/// * The inline block comments that should hang off the preceding element
/// before its comma.
/// * The line comments that should hang off the end of the preceding element
/// after its comma.
/// * The comments that should be formatted like separate elements.
/// * The comments that should lead the beginning of the next element we are
/// about to write.
///
/// For example:
///
/// function(
/// argument /* inline */, // hanging
/// // separate
/// /* leading */ nextArgument
/// );
///
/// Calculating these takes into account whether there are newlines before or
/// after the comments, and which side of the commas the comments appear on.
///
/// If [hasElementAfter] is `true` then another element will be written after
/// these comments. Otherwise, we are at the comments after the last element
/// before the closing delimiter.
({
CommentSequence inline,
CommentSequence hanging,
CommentSequence separate,
CommentSequence leading
}) _splitCommaComments(CommentSequence commentsBeforeElement,
{required bool hasElementAfter}) {
// If we're on the final comma after the last element, the comma isn't
// meaningful because there can't be leading comments after it.
if (!hasElementAfter) {
_commentsBeforeComma =
_commentsBeforeComma.concatenate(commentsBeforeElement);
commentsBeforeElement = CommentSequence.empty;
}
// Edge case: A line comment on the same line as the preceding element
// but after the comma is treated as hanging.
if (commentsBeforeElement.isNotEmpty &&
commentsBeforeElement[0].type == CommentType.line &&
commentsBeforeElement.linesBefore(0) == 0) {
var (hanging, remaining) = commentsBeforeElement.splitAt(1);
_commentsBeforeComma = _commentsBeforeComma.concatenate(hanging);
commentsBeforeElement = remaining;
}
// Inline block comments before the `,` stay with the preceding element, as
// in:
//
// function(
// argument /* hanging */ /* comment */,
// argument,
// );
var inlineCommentCount = 0;
if (_elements.isNotEmpty) {
while (inlineCommentCount < _commentsBeforeComma.length) {
// Once we hit a single non-inline comment, the rest won't be either.
if (!_commentsBeforeComma.isHanging(inlineCommentCount) ||
_commentsBeforeComma[inlineCommentCount].type !=
CommentType.inlineBlock) {
break;
}
inlineCommentCount++;
}
}
var (inlineComments, remainingCommentsBeforeComma) =
_commentsBeforeComma.splitAt(inlineCommentCount);
var hangingCommentCount = 0;
if (_elements.isNotEmpty) {
while (hangingCommentCount < remainingCommentsBeforeComma.length) {
// Once we hit a single non-hanging comment, the rest won't be either.
if (!remainingCommentsBeforeComma.isHanging(hangingCommentCount)) break;
hangingCommentCount++;
}
}
var (hangingComments, separateCommentsBeforeComma) =
remainingCommentsBeforeComma.splitAt(hangingCommentCount);
// Inline block comments on the same line as the next element lead at the
// beginning of that line, as in:
///
// function(
// argument,
// /* leading */ /* comment */ argument,
// );
var leadingCommentCount = 0;
if (hasElementAfter && commentsBeforeElement.isNotEmpty) {
while (leadingCommentCount < commentsBeforeElement.length) {
// Count backwards from the end. Once we hit a non-leading comment, the
// preceding ones aren't either.
var commentIndex =
commentsBeforeElement.length - leadingCommentCount - 1;
if (!commentsBeforeElement.isLeading(commentIndex)) break;
leadingCommentCount++;
}
}
var (separateCommentsAfterComma, leadingComments) = commentsBeforeElement
.splitAt(commentsBeforeElement.length - leadingCommentCount);
// Comments that are neither hanging nor leading are formatted like
// separate elements, as in:
//
// function(
// argument,
// /* comment */
// argument,
// // another
// );
var separateComments =
separateCommentsBeforeComma.concatenate(separateCommentsAfterComma);
return (
inline: inlineComments,
hanging: hangingComments,
separate: separateComments,
leading: leadingComments
);
}
/// Looks at the [BlockFormat] types of all of the elements to determine if
/// one of them should be block formatted.
///
/// Also, if an argument list has an adjacent strings expression followed by a
/// block formattable function expression, we allow the adjacent strings to
/// split without forcing the list to split so that it can continue to have
/// block formatting. This is pretty special-cased, but it makes calls to
/// `test()` and `group()` look better and those are so common that it's
/// worth massaging them some. It allows:
///
/// test('some long description'
/// 'split across multiple lines', () {
/// expect(1, 1);
/// });
///
/// Without this special rule, the newline in the adjacent strings would
/// prevent block formatting and lead to the entire test body to be indented:
///
/// test(
/// 'some long description'
/// 'split across multiple lines',
/// () {
/// expect(1, 1);
/// },
/// );
///
/// Stores the result of this calculation by setting flags on the
/// [ListElement]s.
void _setBlockElementFormatting() {
// TODO(tall): These heuristics will probably need some iteration.
var functions = <int>[];
var others = <int>[];
var adjacentStrings = <int>[];
for (var i = 0; i < _elements.length; i++) {
switch (_elements[i].blockFormat) {
case BlockFormat.function:
functions.add(i);
case BlockFormat.block:
others.add(i);
case BlockFormat.indentedAdjacentStrings:
case BlockFormat.unindentedAdjacentStrings:
adjacentStrings.add(i);
case BlockFormat.none:
break; // Not a block element.
}
}
switch ((functions, others, adjacentStrings)) {
// Only allow block formatting in an argument list containing adjacent
// strings when:
//
// 1. The block argument is a function expression.
// 2. It is the second argument, following an adjacent strings expression.
// 3. There are no other adjacent strings in the argument list.
//
// This matches the `test()` and `group()` and other similar APIs where
// you have a message string followed by a block-like function expression
// but little else.
// TODO(tall): We may want to iterate on these heuristics. For now,
// starting with something very narrowly targeted.
case ([1], _, [0]):
// The adjacent strings.
_elements[0].allowNewlines = true;
if (_elements[0].blockFormat == BlockFormat.unindentedAdjacentStrings) {
_elements[0].indentWhenBlockFormatted = true;
}
// The block-formattable function.
_elements[1].allowNewlines = true;
// A function expression takes precedence over other block arguments.
case ([var blockArgument], _, _):
// Otherwise, if there one block argument, it can be block formatted.
case ([], [var blockArgument], _):
_elements[blockArgument].allowNewlines = true;
}
// If we get here, there are no block arguments, or it's ambiguous as to
// which one should be it so none are.
// TODO(tall): The old formatter allows multiple block arguments, like:
//
// function(() {
// body;
// }, () {
// more;
// });
//
// This doesn't seem very common in the Flutter repo, but does occur
// sometimes. We'll probably want to experiment to see if it's worth
// supporting multiple block arguments. If so, we should at least require
// them to be contiguous with no non-block arguments in the middle.
}
}