blob: 84418494119bde2fa303c3677c753d2b5747497d [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/token.dart';
import '../back_end/code_writer.dart';
import '../back_end/solution_cache.dart';
import '../back_end/solver.dart';
import '../dart_formatter.dart';
import '../debug.dart' as debug;
import '../piece/adjacent.dart';
import '../piece/piece.dart';
import '../source_code.dart';
import 'comment_writer.dart';
/// Builds [TextPiece]s for [Token]s and comments.
///
/// Handles updating selection markers and attaching comments to the tokens
/// before and after the comments.
class PieceWriter {
final DartFormatter _formatter;
final SourceCode _source;
final CommentWriter _comments;
/// The most recent previously-created [CodePiece].
///
/// We hold a reference to this so we can attach hanging comments to it,
/// which we don't discover until we reach the token after the one used to
/// create this piece.
CodePiece? _previousCode;
/// Whether we have reached a token or comment that lies at or beyond the
/// selection start offset in the original code.
///
/// Makes sure we insert the start marker in some piece even if it happens to
/// lie between two tokens in the input.
bool _passedSelectionStart = false;
/// Whether we have reached a token or comment that lies at or beyond the
/// selection end offset in the original code.
///
/// Makes sure we insert the end marker in some piece even if it happens to
/// lie between two tokens in the input.
bool _passedSelectionEnd = false;
/// The character offset of the end of the selection with any trailing
/// whitespace removed.
///
/// This can only be accessed if there is a selection.
late final int _selectionEnd = _findSelectionEnd();
PieceWriter(this._formatter, this._source, this._comments);
/// Creates a piece for [token], including any comments that should be
/// attached to that token.
///
/// If [discardedToken] is given, it is a token immediately before [token]
/// that is going to be discarded. Passing it in here ensures any comments
/// before it are preserved.
///
/// If [commaAfter] is `true`, will look for and write a comma following the
/// token if there is one.
Piece tokenPiece(Token token,
{Token? discardedToken,
bool commaAfter = false,
bool multiline = false}) {
var tokenPiece = _makeCodePiece(
discardedToken: discardedToken, token, multiline: multiline);
if (commaAfter) {
var nextToken = token.next!;
if (nextToken.lexeme == ',') {
return AdjacentPiece([tokenPiece, _makeCodePiece(nextToken)]);
}
}
return tokenPiece;
}
// TODO(tall): Much of the comment handling code in CommentWriter got moved
// into here, so there isn't great separation of concerns anymore. Can we
// organize this code better? Or just combine CommentWriter with this class
// completely?
/// Creates a new [Piece] for [comment] and returns it.
Piece commentPiece(SourceComment comment,
[Whitespace trailingWhitespace = Whitespace.none]) {
var commentPiece = CommentPiece(trailingWhitespace);
_write(commentPiece, comment.text, comment.offset, multiline: true);
return commentPiece;
}
/// Applies any hanging comments before [token] to the preceding [CodePiece]
/// and takes and returns any remaining leading comments.
List<Piece> takeCommentsBefore(Token token) {
return _splitComments(_comments.takeCommentsBefore(token), token);
}
/// Creates a [CodePiece] for [token] and handles any comments that precede
/// it, which get attached either as hanging comments on the preceding
/// [CodePiece] or leading comments on this one.
///
/// If [discardedToken] is given, it is a token immediately before [token]
/// that is going to be discarded. Passing it in here ensures any comments
/// before it are preserved.
///
/// If [multiline] is `true`, then [token]'s lexeme may contain internal
/// newlines. The lexeme will be split into separate lines. If omitted, then
/// [token] must not contain newlines.
CodePiece _makeCodePiece(Token token,
{Token? discardedToken, bool multiline = false}) {
var comments = _comments.commentsBefore(token);
// Include any comments on the preceding discarded token, if there is one.
if (discardedToken != null) {
comments = _comments.commentsBefore(discardedToken).concatenate(comments);
}
var piece = CodePiece(_splitComments(comments, token));
_write(piece, token.lexeme, token.offset, multiline: multiline);
// Remember it so we can attach hanging comments later.
return _previousCode = piece;
}
/// Splits [comments] which precede [token] into [CommentPiece]s that hang
/// off the preceding [CodePiece] and those that are leading comments on the
/// [CodePiece] for [token].
///
/// Attaches hanging comments to [_previousCode]. Returns the list of leading
/// comments that should precede [token].
List<Piece> _splitComments(CommentSequence comments, Token token) {
if (comments.isEmpty) return const [];
var leadingComments = <Piece>[];
for (var i = 0; i < comments.length; i++) {
var comment = comments[i];
// The whitespace after this comment before the next comment or code.
var trailingWhitespace = switch (token.lexeme) {
_ when comment.requiresNewline => Whitespace.newline,
// No space between a comment and delimiting punctuation.
']' || '}' || ',' || ';' => Whitespace.none,
_ => Whitespace.space,
};
var piece = commentPiece(comment, trailingWhitespace);
if (comments.isHanging(i)) {
// Attach it to the previous CodePiece.
_previousCode!.addHangingComment(piece);
} else {
// Add it to the list of leading comments for the upcoming token.
leadingComments.add(piece);
}
}
return leadingComments;
}
/// Appends [text] to [piece] and updates any selection markers that fall
/// within it.
///
/// The [offset] parameter is the offset in the original source code of the
/// beginning of where [text] appears.
void _write(TextPiece piece, String text, int offset,
{bool multiline = false}) {
piece.append(text,
multiline: multiline,
selectionStart: _findSelectionStartWithin(offset, text.length),
selectionEnd: _findSelectionEndWithin(offset, text.length));
}
/// Finishes writing and returns a [SourceCode] containing the final output
/// and updated selection, if any.
SourceCode finish(Piece rootPiece) {
if (debug.tracePieceBuilder) {
debug.log(debug.pieceTree(rootPiece));
}
// See if it's possible to eagerly pin any of the pieces based just on the
// length and newlines in their children. This is faster, especially for
// larger outermost pieces, then relying on the solver to determine their
// state.
void traverse(Piece piece) {
piece.forEachChild(traverse);
if (piece.fixedStateForPageWidth(_formatter.pageWidth - _formatter.indent)
case var state?) {
piece.pin(state);
}
}
traverse(rootPiece);
var cache = SolutionCache();
var formatter = Solver(cache,
pageWidth: _formatter.pageWidth, leadingIndent: _formatter.indent);
var result = formatter.format(rootPiece);
var outputCode = result.text;
// Be a good citizen, end with a newline.
if (_source.isCompilationUnit) outputCode += _formatter.lineEnding!;
int? selectionStart;
int? selectionLength;
if (_source.selectionStart != null) {
selectionStart = result.selectionStart;
var selectionEnd = result.selectionEnd;
// If we haven't hit the beginning and/or end of the selection yet, they
// must be at the very end of the code.
selectionStart ??= outputCode.length;
selectionEnd ??= outputCode.length;
selectionLength = selectionEnd - selectionStart;
}
return SourceCode(outputCode,
uri: _source.uri,
isCompilationUnit: _source.isCompilationUnit,
selectionStart: selectionStart,
selectionLength: selectionLength);
}
/// Returns the number of characters past [position] in the source where the
/// selection start appears if it appears within `position + length`.
///
/// Returns `null` if the selection start has already been processed or is
/// not within that range.
int? _findSelectionStartWithin(int position, int length) {
// If there is no selection, do nothing.
var absoluteStart = _source.selectionStart;
if (absoluteStart == null) return null;
// If we've already passed it, don't consider it again.
if (_passedSelectionStart) return null;
// Calculate the start position relative to [offset].
var relativeStart = absoluteStart - position;
// If it started in whitespace before this text, push it forward to the
// beginning of the non-whitespace text.
if (relativeStart < 0) relativeStart = 0;
// If we haven't reached it yet, don't consider it. If the start point is
// right at the end of the token, don't consider that as reaching it.
// Instead, we'll reach it on the next token, which will correctly push
// it past any whitespace after this token and move it to the beginning of
// the next one.
if (relativeStart >= length) return null;
// We found it.
_passedSelectionStart = true;
return relativeStart;
}
/// Returns the number of characters past [position] in the source where the
/// selection endpoint appears if it appears before `position + length`.
///
/// Returns `null` if the selection endpoint has already been processed or is
/// not within that range.
int? _findSelectionEndWithin(int position, int length) {
// If there is no selection, do nothing.
if (_source.selectionLength == null) return null;
// If we've already passed it, don't consider it again.
if (_passedSelectionEnd) return null;
var relativeEnd = _selectionEnd - position;
// If it started in whitespace before this text, push it forward to the
// beginning of the non-whitespace text.
if (relativeEnd < 0) relativeEnd = 0;
// If we haven't reached the end point yet, don't consider it. Note that,
// unlike [_findSelectionStartWithin], we do consider the end point being
// right at the end of this token to be reaching it. That way, we don't
// push the end point *past* the next span of whitespace and instead pull
// it tight to the end of this text.
if (relativeEnd > length) return null;
// In [_findSelectionStartWithin], if the start marker is between two
// tokens, we push it forward to the next one. In the above statement, we
// push the end marker earlier to the previous token. If the entire
// selection is in whitespace between two tokens, that would cause the
// start and ends to cross. Prevent that and instead push the end marker
// to the beginning of the next token where the start marker will also be
// pushed.
if (relativeEnd == length && _selectionEnd == _source.selectionStart!) {
return null;
}
// We found it.
_passedSelectionEnd = true;
return relativeEnd;
}
/// Calculates the character offset in the source text of the end of the
/// selection.
///
/// Removes any trailing whitespace from the selection. For example, if the
/// original selection markers are:
///
/// function(lotsOfSpac‹eAfter, › andBefore);
///
/// Then this function moves the end marker to:
///
/// function(lotsOfSpac‹eAfter,› andBefore);
///
/// We do this because the formatter itself rewrites whitespace so it's not
/// useful or even meaningful to try to preserve a selection's location within
/// whitespace. Instead, we "rubberband" the end marker forward to the nearest
/// non-whitespace character.
int _findSelectionEnd() {
var end = _source.selectionStart! + _source.selectionLength!;
// If the selection bumps to the end of the source, pin it there.
if (end == _source.text.length) return end;
// Trim off any trailing whitespace.
while (end > _source.selectionStart!) {
// Stop if we hit anything other than space, tab, newline or carriage
// return.
var char = _source.text.codeUnitAt(end - 1);
if (char != 0x20 && char != 0x09 && char != 0x0a && char != 0x0d) {
break;
}
end--;
}
return end;
}
}