blob: 1e2b464925a9a4a375917acba3809a45d0281d6b [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_generated.dart';
import 'package:analysis_server/lsp_protocol/protocol_special.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/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:analyzer_plugin/utilities/pair.dart';
import 'package:dart_style/dart_style.dart';
DartFormatter formatter = DartFormatter();
/// 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<Pair<String, List<plugin.SourceEdit>>> applyAndConvertEditsToServer(
String oldContent,
List<
Either2<TextDocumentContentChangeEvent1,
TextDocumentContentChangeEvent2>>
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(Pair(newContent, serverEdits));
}
ErrorOr<List<TextEdit>?> generateEditsForFormatting(
ParsedUnitResult result,
int? lineLength, {
Range? range,
}) {
final unformattedSource = result.content;
final code =
SourceCode(unformattedSource, uri: null, isCompilationUnit: true);
SourceCode formattedResult;
try {
// If the lineLength has changed, recreate the formatter with the new setting.
if (lineLength != formatter.pageWidth) {
formatter = DartFormatter(pageWidth: lineLength);
}
formattedResult = formatter.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 (only whitespace) to
/// change [unformatted] 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 whitespace 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. 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 (unformattedWhitespace.trim().isNotEmpty ||
formattedWhitespace.trim().isNotEmpty) {
return;
}
var startOffset = unformattedStart;
var endOffset = unformattedEnd;
var newText = formattedWhitespace;
// Simplify some common cases where the new whitespace is a subset of
// the old.
if (formattedWhitespace.isNotEmpty) {
if (unformattedWhitespace.startsWith(formattedWhitespace)) {
startOffset = unformattedStart + formattedWhitespace.length;
newText = '';
} else if (unformattedWhitespace.endsWith(formattedWhitespace)) {
endOffset = unformattedEnd - formattedWhitespace.length;
newText = '';
}
}
// 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,
));
}
// Process the whitespace before each token.
bool unformattedHasMore, formattedHasMore;
while ((unformattedHasMore =
unformattedTokens.moveNext()) & // Don't short-circuit
(formattedHasMore = formattedTokens.moveNext())) {
final unformattedToken = unformattedTokens.current;
final formattedToken = formattedTokens.current;
if (unformattedToken.lexeme != formattedToken.lexeme) {
// If the token lexems 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));
}
addEditFor(
unformattedOffset,
unformattedToken.offset,
formattedOffset,
formattedToken.offset,
);
// 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 && unformattedToken.end > rangeEnd.result) {
return success(edits);
}
unformattedOffset = unformattedToken.end;
formattedOffset = formattedToken.end;
}
// 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.type != TokenType.EOF) {
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;
}
}
/// Helper class that bundles up all information required when converting server
/// SourceEdits into LSP-compatible WorkspaceEdits.
class FileEditInformation {
final OptionalVersionedTextDocumentIdentifier doc;
final LineInfo lineInfo;
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,
{this.newFile = false,
this.selectionOffsetRelative,
this.selectionLength});
}
class _SourceMock implements Source {
static final Source instance = _SourceMock();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}