blob: 6c01ec8d7c6f4d92f910641bf97f7296a28733bf [file] [log] [blame]
// Copyright (c) 2018, 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:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/protocol_server.dart' as server
show SourceEdit;
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source.dart';
import 'package:analyzer/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:dart_style/dart_style.dart';
/// Checks whether a string contains only whitespace and commas.
final _isWhitespaceAndCommas = RegExp(r'^[\s,]*$').hasMatch;
/// Transforms a sequence of LSP document change events to a sequence of source
/// edits used by analysis plugins.
///
/// Since the translation from line/characters to offsets needs to take previous
/// changes into account, this will also apply the edits to [oldContent].
ErrorOr<({String content, List<plugin.SourceEdit> edits})>
applyAndConvertEditsToServer(
String oldContent,
List<TextDocumentContentChangeEvent> changes, {
bool failureIsCritical = false,
}) {
var newContent = oldContent;
final serverEdits = <server.SourceEdit>[];
for (var change in changes) {
// Change is a union that may/may not include a range. If no range
// is provided (t2 of the union) the whole document should be replaced.
final result = change.map(
// TextDocumentContentChangeEvent1
// {range, text}
(change) {
final lines = LineInfo.fromContent(newContent);
final offsetStart = toOffset(lines, change.range.start,
failureIsCritical: failureIsCritical);
final offsetEnd = toOffset(lines, change.range.end,
failureIsCritical: failureIsCritical);
if (offsetStart.isError) {
return ErrorOr.error(offsetStart.error);
}
if (offsetEnd.isError) {
return ErrorOr.error(offsetEnd.error);
}
newContent = newContent.replaceRange(
offsetStart.result, offsetEnd.result, change.text);
serverEdits.add(server.SourceEdit(offsetStart.result,
offsetEnd.result - offsetStart.result, change.text));
},
// TextDocumentContentChangeEvent2
// {text}
(change) {
serverEdits
..clear()
..add(server.SourceEdit(0, newContent.length, change.text));
newContent = change.text;
},
);
// If any change fails, immediately return the error.
if (result != null && result.isError) {
return ErrorOr.error(result.error);
}
}
return ErrorOr.success((content: newContent, edits: serverEdits));
}
ErrorOr<List<TextEdit>?> generateEditsForFormatting(
ParsedUnitResult result,
int? lineLength, {
Range? range,
}) {
final unformattedSource = result.content;
final code = SourceCode(unformattedSource);
SourceCode formattedResult;
try {
// Create a new formatter on every request because it may contain state that
// affects repeated formats.
// https://github.com/dart-lang/dart_style/issues/1337
formattedResult = DartFormatter(pageWidth: lineLength).formatSource(code);
} on FormatterException {
// If the document fails to parse, just return no edits to avoid the
// use seeing edits on every save with invalid code (if LSP gains the
// ability to pass a context to know if the format was manually invoked
// we may wish to change this to return an error for that case).
return success(null);
}
final formattedSource = formattedResult.text;
if (formattedSource == unformattedSource) {
return success(null);
}
return generateMinimalEdits(result, formattedSource, range: range);
}
List<TextEdit> generateFullEdit(
LineInfo lineInfo, String unformattedSource, String formattedSource) {
final end = lineInfo.getLocation(unformattedSource.length);
return [
TextEdit(
range:
Range(start: Position(line: 0, character: 0), end: toPosition(end)),
newText: formattedSource,
)
];
}
/// Generates edits that modify the minimum amount of code (if only whitespace,
/// commas and comments) to change the source of [result] to [formatted].
///
/// This allows editors to more easily track important locations (such as
/// breakpoints) without needing to do their own diffing.
///
/// If [range] is supplied, only edits that fall entirely inside this range will
/// be included in the results.
ErrorOr<List<TextEdit>> generateMinimalEdits(
ParsedUnitResult result,
String formatted, {
Range? range,
}) {
final unformatted = result.content;
final lineInfo = result.lineInfo;
final rangeStart = range != null ? toOffset(lineInfo, range.start) : null;
final rangeEnd = range != null ? toOffset(lineInfo, range.end) : null;
if (rangeStart != null && rangeStart.isError) {
return failure(rangeStart);
}
if (rangeEnd != null && rangeEnd.isError) {
return failure(rangeEnd);
}
// It shouldn't be the case that we can't parse the code but if it happens
// fall back to a full replacement rather than fail.
final parsedFormatted = _parse(formatted, result.unit.featureSet);
final parsedUnformatted = _parse(unformatted, result.unit.featureSet);
if (parsedFormatted == null || parsedUnformatted == null) {
return success(generateFullEdit(lineInfo, unformatted, formatted));
}
final unformattedTokens = _iterateAllTokens(parsedUnformatted).iterator;
final formattedTokens = _iterateAllTokens(parsedFormatted).iterator;
var unformattedOffset = 0;
var formattedOffset = 0;
final edits = <TextEdit>[];
/// Helper for comparing whitespace and appending an edit.
void addEditFor(
int unformattedStart,
int unformattedEnd,
int formattedStart,
int formattedEnd,
) {
final unformattedWhitespace =
unformatted.substring(unformattedStart, unformattedEnd);
final formattedWhitespace =
formatted.substring(formattedStart, formattedEnd);
if (rangeStart != null && rangeEnd != null) {
// If this change crosses over the start of the requested range,
// discarding the change may result in leading whitespace of the next line
// not being formatted correctly.
//
// To handle this, if both unformatted/formatted contain at least one
// newline, split this change into two around the last newline so that the
// final part (likely leading whitespace) can be included without
// including the whole change. This cannot be done if the newline is at
// the end of the source whitespace though, as this would create a split
// where the first part is the same and the second part is empty,
// resulting in an infinite loop/stack overflow.
//
// Without this, functionality like VS Code's "format modified lines"
// (which uses Git status to know which lines are edited) may appear to
// fail to format the first newly added line in a range.
if (unformattedStart < rangeStart.result &&
unformattedEnd > rangeStart.result &&
unformattedWhitespace.contains('\n') &&
formattedWhitespace.contains('\n') &&
!unformattedWhitespace.endsWith('\n')) {
// Find the offsets of the character after the last newlines.
final unformattedOffset = unformattedWhitespace.lastIndexOf('\n') + 1;
final formattedOffset = formattedWhitespace.lastIndexOf('\n') + 1;
// Call us again for the leading part
addEditFor(
unformattedStart,
unformattedStart + unformattedOffset,
formattedStart,
formattedStart + formattedOffset,
);
// Call us again for the trailing part
addEditFor(
unformattedStart + unformattedOffset,
unformattedEnd,
formattedStart + formattedOffset,
formattedEnd,
);
return;
}
// If we're formatting only a range, skip over any segments that don't
// fall entirely within that range.
if (unformattedStart < rangeStart.result ||
unformattedEnd > rangeEnd.result) {
return;
}
}
if (unformattedWhitespace == formattedWhitespace) {
return;
}
// Validate we didn't find more than whitespace or commas. If this occurs,
// it's likely the token offsets used were incorrect. In this case it's
// better to not modify the code than potentially remove something
// important.
if (!_isWhitespaceAndCommas(unformattedWhitespace) ||
!_isWhitespaceAndCommas(formattedWhitespace)) {
return;
}
var startOffset = unformattedStart;
var endOffset = unformattedEnd;
var oldText = unformattedWhitespace;
var newText = formattedWhitespace;
// Simplify some common cases where the new whitespace is a subset of
// the old.
// Remove common prefixes.
int commonPrefixLength = 0;
while (commonPrefixLength < oldText.length &&
commonPrefixLength < newText.length &&
oldText[commonPrefixLength] == newText[commonPrefixLength]) {
commonPrefixLength++;
}
if (commonPrefixLength != 0) {
oldText = oldText.substring(commonPrefixLength);
newText = newText.substring(commonPrefixLength);
startOffset += commonPrefixLength;
}
// Remove common suffixes.
int commonSuffixLength = 0;
while (commonSuffixLength < oldText.length &&
commonSuffixLength < newText.length &&
oldText[oldText.length - 1 - commonSuffixLength] ==
newText[newText.length - 1 - commonSuffixLength]) {
commonSuffixLength++;
}
if (commonSuffixLength != 0) {
oldText = oldText.substring(0, oldText.length - commonSuffixLength);
newText = newText.substring(0, newText.length - commonSuffixLength);
endOffset -= commonSuffixLength;
}
// Finally, append the edit for this whitespace.
// Note: As with all LSP edits, offsets are based on the original location
// as they are applied in one shot. They should not account for the previous
// edits in the same set.
edits.add(TextEdit(
range: Range(
start: toPosition(lineInfo.getLocation(startOffset)),
end: toPosition(lineInfo.getLocation(endOffset)),
),
newText: newText,
));
}
// Walk through the token streams computing edits for the differences.
bool unformattedHasMore, formattedHasMore;
while ((unformattedHasMore =
unformattedTokens.moveNext()) & // Don't short-circuit.
(formattedHasMore = formattedTokens.moveNext())) {
var unformattedToken = unformattedTokens.current;
var formattedToken = formattedTokens.current;
// Compute the ranges from each side that that we will produce an edit for.
// This is usually just the whitespace from each side (the range between the
// end of the previous token and the start of the current), but in the case
// of commas will be expanded to include the commas (and then the following
// whitespace).
var unformattedStart = unformattedOffset;
var unformattedEnd = unformattedToken.offset;
var formattedStart = formattedOffset;
var formattedEnd = formattedToken.offset;
if (formattedToken.type == TokenType.COMMA &&
unformattedToken.type != TokenType.COMMA) {
// Push the end of the range back to include the comma and subsequent
// whitespace.
// Don't use `formattedToken.next?.offset`, that would skip comments.
formattedEnd = formattedToken.end;
if (formattedHasMore = formattedTokens.moveNext()) {
formattedToken = formattedTokens.current;
formattedEnd = formattedTokens.current.offset;
}
} else if (unformattedToken.type == TokenType.COMMA &&
formattedToken.type != TokenType.COMMA) {
// Push the end of the range back to include the comma and subsequent
// whitespace.
// Don't use `unformattedToken.next?.offset`, that would skip comments.
unformattedEnd = unformattedToken.end;
if (unformattedHasMore = unformattedTokens.moveNext()) {
unformattedToken = unformattedTokens.current;
unformattedEnd = unformattedTokens.current.offset;
}
}
if (unformattedToken.lexeme != formattedToken.lexeme) {
// If the token lexemes do not match, there is a difference in the parsed
// token streams (this should not ordinarily happen) so fall back to a
// full edit.
return success(generateFullEdit(lineInfo, unformatted, formatted));
}
// Add edits for the computed ranges.
addEditFor(unformattedStart, unformattedEnd, formattedStart, formattedEnd);
// And move the pointers along to after these tokens.
unformattedOffset = unformattedToken.end;
formattedOffset = formattedToken.end;
// When range formatting, if we've processed a token that ends after the
// range then there can't be any more relevant edits and we can return early.
if (rangeEnd != null && unformattedOffset > rangeEnd.result) {
return success(edits);
}
}
// If we got here and either of the streams still have tokens, something
// did not match so fall back to a full edit.
if (unformattedHasMore || formattedHasMore) {
return success(generateFullEdit(lineInfo, unformatted, formatted));
}
// Finally, handle any whitespace that was after the last token.
addEditFor(
unformattedOffset,
unformatted.length,
formattedOffset,
formatted.length,
);
return success(edits);
}
/// Iterates over a token stream returning all tokens including comments.
Iterable<Token> _iterateAllTokens(Token token) sync* {
while (!token.isEof) {
Token? commentToken = token.precedingComments;
while (commentToken != null) {
yield commentToken;
commentToken = commentToken.next;
}
yield token;
token = token.next!;
}
}
/// Parse and return the first of the given Dart source, `null` if code cannot
/// be parsed.
Token? _parse(String s, FeatureSet featureSet) {
try {
var scanner = Scanner(_SourceMock.instance, CharSequenceReader(s),
AnalysisErrorListener.NULL_LISTENER)
..configureFeatures(
featureSetForOverriding: featureSet,
featureSet: featureSet,
);
return scanner.tokenize();
} catch (e) {
return null;
}
}
enum ChangeAnnotations {
/// Do not include change annotations.
none,
/// Include change annotations but do not require a user to confirm changes.
include,
/// Include change annotations and require the user to confirm changes.
requireConfirmation,
}
/// Helper class that bundles up all information required when converting server
/// SourceEdits into LSP-compatible WorkspaceEdits.
class FileEditInformation {
final OptionalVersionedTextDocumentIdentifier doc;
final LineInfo lineInfo;
/// A list of edits to be made to the file.
///
/// These edits must be sorted using servers rules (as in `SourceFileEdit`s).
///
/// Server works with edits that can be applied sequentially to a [String]. This
/// means inserts at the same offset are in the reverse order. For LSP, all
/// offsets relate to the original document and inserts with the same offset
/// appear in the order they will appear in the final document.
final List<server.SourceEdit> edits;
final bool newFile;
/// The selection offset, relative to the edit.
final int? selectionOffsetRelative;
final int? selectionLength;
FileEditInformation(
this.doc,
this.lineInfo,
this.edits, {
required this.newFile,
this.selectionOffsetRelative,
this.selectionLength,
});
}
class _SourceMock implements Source {
static final Source instance = _SourceMock();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}