Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Format multi-line strings and string interpolation. #1362

Merged
merged 3 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions lib/src/ast_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,6 @@ extension ExpressionExtensions on Expression {
expression = expression.expression;
}

// TODO(tall): We should also allow multi-line strings to be formatted
// like block arguments, at least in some cases like:
//
// function('''
// Lots of
// text
// ''');

// TODO(tall): Consider whether immediately-invoked function expressions
// should be block argument candidates, like:
//
Expand All @@ -177,16 +169,26 @@ extension ExpressionExtensions on Expression {
parameters.parameters.canSplit(parameters.rightParenthesis) ||
(body is BlockFunctionBody &&
body.block.statements.canSplit(body.block.rightBracket)),

// Non-empty collection literals can block split.
ListLiteral(:var elements, :var rightBracket) ||
SetOrMapLiteral(:var elements, :var rightBracket) =>
elements.canSplit(rightBracket),
RecordLiteral(:var fields, :var rightParenthesis) =>
fields.canSplit(rightParenthesis),
SwitchExpression(:var cases, :var rightBracket) =>
cases.canSplit(rightBracket),

// Function calls can block split if their argument lists can.
InstanceCreationExpression(:var argumentList) ||
MethodInvocation(:var argumentList) =>
argumentList.arguments.canSplit(argumentList.rightParenthesis),

// Multi-line strings can.
StringInterpolation(isMultiline: true) => true,
SimpleStringLiteral(isMultiline: true) => true,

// Parenthesized expressions can if the inner one can.
ParenthesizedExpression(:var expression) => expression.canBlockSplit,
_ => false,
};
Expand Down
47 changes: 23 additions & 24 deletions lib/src/back_end/code_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,22 +128,6 @@ class CodeWriter {
isValid: !_hasInvalidNewline, overflow: _overflow, cost: _cost);
}

/// Notes that a newline has been written.
///
/// If this occurs in a place where newlines are prohibited, then invalidates
/// the solution.
///
/// This is called externally by [TextPiece] to let the writer know some of
/// the raw text contains a newline, which can happen in multi-line block
/// comments and multi-line string literals.
void handleNewline() {
if (!_options.allowNewlines) _hasInvalidNewline = true;

// Note that this piece contains a newline so that we can propagate that
// up to containing pieces too.
_options.hasNewline = true;
}

/// Appends [text] to the output.
///
/// If [text] contains any internal newlines, the caller is responsible for
Expand Down Expand Up @@ -204,16 +188,21 @@ class CodeWriter {
///
/// If [indent] is given, set the indentation of the new line (and all
/// subsequent lines) to that indentation relative to the containing piece.
void newline({bool blank = false, int? indent}) {
///
/// If [flushLeft] is `true`, then the new line begins at column 1 and ignores
/// any surrounding indentation. This is used for multi-line block comments
/// and multi-line strings.
void newline({bool blank = false, int? indent, bool flushLeft = false}) {
if (indent != null) setIndent(indent);

whitespace(blank ? Whitespace.blankLine : Whitespace.newline);
whitespace(blank ? Whitespace.blankLine : Whitespace.newline,
flushLeft: flushLeft);
}

void whitespace(Whitespace whitespace) {
void whitespace(Whitespace whitespace, {bool flushLeft = false}) {
if (whitespace case Whitespace.newline || Whitespace.blankLine) {
handleNewline();
_pendingIndent = _options.indent;
_handleNewline();
_pendingIndent = flushLeft ? 0 : _options.indent;
}

_pendingWhitespace = _pendingWhitespace.collapse(whitespace);
Expand Down Expand Up @@ -246,9 +235,7 @@ class CodeWriter {
var childOptions = _pieceOptions.removeLast();

// If the child [piece] contains a newline then this one transitively does.
// TODO(tall): At some point, we may want to provide an API so that pieces
// can block this from propagating outward.
if (childOptions.hasNewline) handleNewline();
if (childOptions.hasNewline) _handleNewline();
}

/// Format [piece] if not null.
Expand All @@ -272,6 +259,18 @@ class CodeWriter {
_selectionEnd = _buffer.length + end;
}

/// Notes that a newline has been written.
///
/// If this occurs in a place where newlines are prohibited, then invalidates
/// the solution.
void _handleNewline() {
if (!_options.allowNewlines) _hasInvalidNewline = true;

// Note that this piece contains a newline so that we can propagate that
// up to containing pieces too.
_options.hasNewline = true;
}

/// Write any pending whitespace.
///
/// This is called before non-whitespace text is about to be written, or
Expand Down
18 changes: 14 additions & 4 deletions lib/src/front_end/ast_node_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1070,12 +1070,17 @@ class AstNodeVisitor extends ThrowingAstVisitor<Piece> with PieceFactory {

@override
Piece visitInterpolationExpression(InterpolationExpression node) {
throw UnimplementedError();
return buildPiece((b) {
b.token(node.leftBracket);
b.visit(node.expression);
b.token(node.rightBracket);
});
}

@override
Piece visitInterpolationString(InterpolationString node) {
throw UnimplementedError();
return pieces.stringLiteralPiece(node.contents,
isMultiline: (node.parent as StringInterpolation).isMultiline);
}

@override
Expand Down Expand Up @@ -1521,7 +1526,8 @@ class AstNodeVisitor extends ThrowingAstVisitor<Piece> with PieceFactory {

@override
Piece visitSimpleStringLiteral(SimpleStringLiteral node) {
return tokenPiece(node.literal);
return pieces.stringLiteralPiece(node.literal,
isMultiline: node.isMultiline);
}

@override
Expand All @@ -1534,7 +1540,11 @@ class AstNodeVisitor extends ThrowingAstVisitor<Piece> with PieceFactory {

@override
Piece visitStringInterpolation(StringInterpolation node) {
throw UnimplementedError();
return buildPiece((b) {
for (var element in node.elements) {
b.visit(element);
}
});
}

@override
Expand Down
49 changes: 41 additions & 8 deletions lib/src/front_end/piece_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import '../piece/piece.dart';
import '../source_code.dart';
import 'comment_writer.dart';

/// RegExp that matches any valid Dart line terminator.
final _lineTerminatorPattern = RegExp(r'\r\n?|\n');

/// Builds [TextPiece]s for [Token]s and comments.
///
/// Handles updating selection markers and attaching comments to the tokens
Expand Down Expand Up @@ -69,6 +72,22 @@ class PieceWriter {
return tokenPiece;
}

/// Creates a piece for a simple or interpolated string [literal].
///
/// Handles splitting it into multiple lines in the resulting [TextPiece] if
/// [isMultiline] is `true`.
Piece stringLiteralPiece(Token literal, {required bool isMultiline}) {
if (!isMultiline) return tokenPiece(literal);

if (!_writeCommentsBefore(literal)) {
// We want this token to be in its own TextPiece, so if the comments
// didn't already lead to ending the previous TextPiece than do so now.
_currentText = TextPiece();
}

return _writeMultiLine(literal.lexeme, offset: literal.offset);
}

// 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
Expand All @@ -95,9 +114,7 @@ class PieceWriter {
Piece writeComment(SourceComment comment) {
_currentText = TextPiece();

_write(comment.text,
offset: comment.offset, containsNewline: comment.text.contains('\n'));
return _currentText;
return _writeMultiLine(comment.text, offset: comment.offset);
}

/// Writes all of the comments that appear between [token] and the previous
Expand Down Expand Up @@ -146,8 +163,7 @@ class PieceWriter {
_currentText.newline();
}

_write(comment.text,
offset: comment.offset, containsNewline: comment.text.contains('\n'));
_write(comment.text, offset: comment.offset);
}

// Output a trailing newline after the last comment if it needs one.
Expand Down Expand Up @@ -180,14 +196,31 @@ class PieceWriter {
_currentText = TextPiece();
}

_write(lexeme ?? token.lexeme, offset: token.offset);
lexeme ??= token.lexeme;

_write(lexeme, offset: token.offset);
}

/// Writes multi-line [text] to the current [TextPiece].
///
/// Handles breaking [text] into lines and adding them to the [TextPiece].
Piece _writeMultiLine(String lexeme, {required int offset}) {
var lines = lexeme.split(_lineTerminatorPattern);
var currentOffset = offset;
for (var i = 0; i < lines.length; i++) {
if (i > 0) _currentText.newline(flushLeft: true);
_write(lines[i], offset: currentOffset);
currentOffset += lines[i].length;
}

return _currentText;
}

/// Writes [text] to the current [TextPiece].
///
/// If [offset] is given and it contains any selection markers, then attaches
/// those markers to the [TextPiece].
void _write(String text, {bool containsNewline = false, int? offset}) {
void _write(String text, {int? offset}) {
if (offset != null) {
// If this text contains any of the selection endpoints, note their
// relative locations in the text piece.
Expand All @@ -200,7 +233,7 @@ class PieceWriter {
}
}

_currentText.append(text, containsNewline: containsNewline);
_currentText.append(text);
}

/// Finishes writing and returns a [SourceCode] containing the final output
Expand Down
67 changes: 42 additions & 25 deletions lib/src/piece/piece.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,7 @@ class TextPiece extends Piece {
/// preceding comments that are on their own line will have multiple. These
/// are stored as separate lines instead of a single multi-line string so that
/// each line can be indented appropriately during formatting.
final List<String> _lines = [];

/// True if this text piece contains or ends with a mandatory newline.
///
/// This can be from line comments, block comments with newlines inside,
/// multiline strings, etc.
bool _containsNewline = false;
final List<_Line> _lines = [];

/// Whitespace at the end of this [TextPiece].
///
Expand All @@ -116,6 +110,14 @@ class TextPiece extends Piece {
/// empty [_lines] list on the first write.
Whitespace _trailingWhitespace = Whitespace.newline;

/// Whether the line after the next newline written should be fixed at column
/// one or indented to match the surrounding code.
///
/// This is false for most lines, but is true for multiline strings where
/// subsequent lines in the string don't get any additional indentation from
/// formatting.
bool _flushLeft = false;

/// The offset from the beginning of [text] where the selection starts, or
/// `null` if the selection does not start within this chunk.
int? _selectionStart;
Expand All @@ -125,48 +127,43 @@ class TextPiece extends Piece {
int? _selectionEnd;

/// Whether the last line of this piece's text ends with [text].
bool endsWith(String text) => _lines.isNotEmpty && _lines.last.endsWith(text);
bool endsWith(String text) =>
_lines.isNotEmpty && _lines.last._text.endsWith(text);

/// Append [text] to the end of this piece.
///
/// If [text] internally contains a newline, then [containsNewline] should
/// be `true`.
void append(String text, {bool containsNewline = false}) {
void append(String text) {
// Write any pending whitespace into the text.
switch (_trailingWhitespace) {
case Whitespace.none:
break; // Nothing to do.
case Whitespace.space:
// TODO(perf): Consider a faster way of accumulating text.
_lines.last += ' ';
_lines.last.append(' ');
case Whitespace.newline:
_lines.add('');
_lines.add(_Line(flushLeft: _flushLeft));
case Whitespace.blankLine:
throw UnsupportedError('No blank lines in TextPieces.');
}

_trailingWhitespace = Whitespace.none;
_flushLeft = false;

// TODO(perf): Consider a faster way of accumulating text.
_lines.last = _lines.last + text;

if (containsNewline) _containsNewline = true;
_lines.last.append(text);
}

void space() {
_trailingWhitespace = _trailingWhitespace.collapse(Whitespace.space);
}

void newline() {
void newline({bool flushLeft = false}) {
_trailingWhitespace = _trailingWhitespace.collapse(Whitespace.newline);
_flushLeft = flushLeft;
}

@override
void format(CodeWriter writer, State state) {
// Let the writer know if there are any embedded newlines even if there is
// only one "line" in [_lines].
if (_containsNewline) writer.handleNewline();

if (_selectionStart case var start?) {
writer.startSelection(start);
}
Expand All @@ -176,8 +173,9 @@ class TextPiece extends Piece {
}

for (var i = 0; i < _lines.length; i++) {
if (i > 0) writer.newline();
writer.write(_lines[i]);
var line = _lines[i];
if (i > 0) writer.newline(flushLeft: line._isFlushLeft);
writer.write(line._text);
}

writer.whitespace(_trailingWhitespace);
Expand All @@ -201,7 +199,7 @@ class TextPiece extends Piece {
/// Adjust [offset] by the current length of this [TextPiece].
int _adjustSelection(int offset) {
for (var line in _lines) {
offset += line.length;
offset += line._text.length;
}

if (_trailingWhitespace == Whitespace.space) offset++;
Expand All @@ -210,7 +208,26 @@ class TextPiece extends Piece {
}

@override
String toString() => '`${_lines.join('¬')}`${_containsNewline ? '!' : ''}';
String toString() => '`${_lines.join('¬')}`';
}

/// A single line of text within a [TextPiece].
class _Line {
String _text = '';

/// Whether this line should start at column one or use the surrounding
/// indentation.
final bool _isFlushLeft;

_Line({required bool flushLeft}) : _isFlushLeft = flushLeft;

void append(String text) {
// TODO(perf): Consider a faster way of accumulating text.
_text += text;
}

@override
String toString() => _text;
}

/// A piece that writes a single space.
Expand Down
Loading