| // 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:analysis_server/src/utilities/extensions/formatter_options.dart'; |
| 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' hide TrailingCommas; |
| |
| /// Checks whether a string contains only characters that are allowed to differ |
| /// between unformattedformatted code (such as whitespace, commas, semicolons). |
| final _isValidFormatterChange = 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; |
| var 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. |
| var result = change.map( |
| // TextDocumentContentChangeEvent1 |
| // {range, text} |
| (change) { |
| var lines = LineInfo.fromContent(newContent); |
| var offsetStart = toOffset( |
| lines, |
| change.range.start, |
| failureIsCritical: failureIsCritical, |
| ); |
| var offsetEnd = toOffset( |
| lines, |
| change.range.end, |
| failureIsCritical: failureIsCritical, |
| ); |
| if (offsetStart.isError) { |
| return failure(offsetStart); |
| } |
| if (offsetEnd.isError) { |
| return failure(offsetEnd); |
| } |
| (offsetStart, offsetEnd).ifResults((offsetStart, offsetEnd) { |
| newContent = newContent.replaceRange( |
| offsetStart, |
| offsetEnd, |
| change.text, |
| ); |
| serverEdits.add( |
| server.SourceEdit( |
| offsetStart, |
| offsetEnd - offsetStart, |
| 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 failure(result); |
| } |
| } |
| return ErrorOr.success((content: newContent, edits: serverEdits)); |
| } |
| |
| /// Generates a list of [TextEdit]s to format the code for [result]. |
| /// |
| /// [defaultPageWidth] will be used as the default page width if [result] does |
| /// not have an analysis_options file that defines a page width. |
| /// |
| /// If [range] is provided, only edits that intersect with this range will be |
| /// returned. |
| ErrorOr<List<TextEdit>?> generateEditsForFormatting( |
| ParsedUnitResult result, { |
| int? defaultPageWidth, |
| Range? range, |
| }) { |
| var unformattedSource = result.content; |
| |
| var formatterOptions = result.analysisOptions.formatterOptions; |
| // The analysis options page width always takes priority over the default from |
| // the LSP configuration. |
| var effectivePageWidth = formatterOptions.pageWidth ?? defaultPageWidth; |
| var effectiveTrailingCommas = formatterOptions.dartStyleTrailingCommas; |
| var effectiveLanguageVersion = result.unit.languageVersion.effective; |
| |
| var 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 |
| var formatter = DartFormatter( |
| pageWidth: effectivePageWidth, |
| trailingCommas: effectiveTrailingCommas, |
| languageVersion: effectiveLanguageVersion, |
| ); |
| 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); |
| } |
| var formattedSource = formattedResult.text; |
| |
| if (formattedSource == unformattedSource) { |
| return success(null); |
| } |
| |
| return generateMinimalEdits(result, formattedSource, range: range); |
| } |
| |
| List<TextEdit> generateFullEdit( |
| LineInfo lineInfo, |
| String unformattedSource, |
| String formattedSource, |
| ) { |
| var 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, |
| }) { |
| var unformatted = result.content; |
| var lineInfo = result.lineInfo; |
| var rangeStart = |
| range != null ? toOffset(lineInfo, range.start) : success(null); |
| var rangeEnd = range != null ? toOffset(lineInfo, range.end) : success(null); |
| |
| return (rangeStart, rangeEnd).mapResultsSync((rangeStart, rangeEnd) { |
| var computer = _MinimalEditComputer( |
| result: result, |
| lineInfo: lineInfo, |
| unformatted: unformatted, |
| formatted: formatted, |
| rangeStart: rangeStart, |
| rangeEnd: rangeEnd, |
| ); |
| var edits = computer.computeMinimalEdits(); |
| return success(edits); |
| }); |
| } |
| |
| 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, |
| }); |
| } |
| |
| /// Computes minimal edits to translate a set of parseable source code into |
| /// another (usually produced by the formatter). |
| class _MinimalEditComputer { |
| // A set of tokens that the formatter may add or remove, which we will |
| // allow and not abort for. |
| static const allowedAddOrRemovedTokens = { |
| TokenType.COMMA, |
| TokenType.SEMICOLON, |
| }; |
| |
| // A set of tokens that the formatter may alter the lexem for but must still |
| // match types. |
| static const allowedLexemeDifferences = { |
| // Comments may have trailing spaces removed from lines. |
| TokenType.MULTI_LINE_COMMENT, |
| TokenType.SINGLE_LINE_COMMENT, |
| }; |
| |
| // A map of tokens that are allowed to be substituted as a result of |
| // format changes and the set of possible sequences they may be replaced |
| // with. |
| static const allowedSubstitutions = { |
| TokenType.GT_GT_GT: { |
| [TokenType.GT, TokenType.GT, TokenType.GT], |
| [TokenType.GT, TokenType.GT_GT], |
| [TokenType.GT_GT, TokenType.GT], |
| }, |
| TokenType.GT_GT: { |
| [TokenType.GT, TokenType.GT], |
| }, |
| TokenType.LT_LT: { |
| [TokenType.LT, TokenType.LT], |
| }, |
| TokenType.INDEX: { |
| [TokenType.OPEN_SQUARE_BRACKET, TokenType.CLOSE_SQUARE_BRACKET], |
| }, |
| }; |
| |
| final LineInfo _lineInfo; |
| final String _unformatted; |
| final String _formatted; |
| final int? _rangeStart; |
| |
| final int? _rangeEnd; |
| final Token? _parsedUnformatted; |
| |
| final Token? _parsedFormatted; |
| |
| /// The edits being built. |
| final _edits = <TextEdit>[]; |
| |
| _MinimalEditComputer({ |
| required ParsedUnitResult result, |
| required LineInfo lineInfo, |
| required String unformatted, |
| required String formatted, |
| required int? rangeStart, |
| required int? rangeEnd, |
| }) : _lineInfo = lineInfo, |
| _unformatted = unformatted, |
| _formatted = formatted, |
| _rangeStart = rangeStart, |
| _rangeEnd = rangeEnd, |
| _parsedUnformatted = _parse(unformatted, result.unit.featureSet), |
| _parsedFormatted = _parse(formatted, result.unit.featureSet); |
| |
| /// Compute the edits. |
| /// |
| /// If for any reason edits cannot be computed, then: |
| /// |
| /// - If a range was specified, no edits will be returned. |
| /// - If no range was specified, a single edit for the entire document will be |
| /// returned. |
| List<TextEdit> computeMinimalEdits() { |
| if (_edits.isNotEmpty) { |
| throw StateError('computeMinimalEdits may only used once per instance.'); |
| } |
| |
| // 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. |
| if (_parsedFormatted == null || _parsedUnformatted == null) { |
| return generateFullEdit(_lineInfo, _unformatted, _formatted); |
| } |
| |
| var unformattedTokens = _iterateAllTokens(_parsedUnformatted).iterator; |
| var formattedTokens = _iterateAllTokens(_parsedFormatted).iterator; |
| |
| var unformattedOffset = 0; |
| var formattedOffset = 0; |
| |
| // 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; |
| var allowAnyContentDifferences = false; |
| |
| /// Helper to advance the formatted stream by [count] tokens if it is not |
| /// at the end. |
| void advanceFormatted([int count = 1]) { |
| // Don't use `formattedToken.next?.offset`, that would skip comments. |
| formattedEnd = formattedToken.end; |
| for (int i = 0; i < count; i++) { |
| if (formattedHasMore = formattedTokens.moveNext()) { |
| formattedToken = formattedTokens.current; |
| formattedEnd = formattedToken.offset; |
| } |
| } |
| } |
| |
| /// Helper to advance the unformatted stream by [count] tokens if it is |
| /// not at the end. |
| void advanceUnformatted([int count = 1]) { |
| // Don't use `unformattedToken.next?.offset`, that would skip comments. |
| unformattedEnd = unformattedToken.end; |
| for (int i = 0; i < count; i++) { |
| if (unformattedHasMore = unformattedTokens.moveNext()) { |
| unformattedToken = unformattedTokens.current; |
| unformattedEnd = unformattedToken.offset; |
| } |
| } |
| } |
| |
| // We may need to advance multiple times if multiple allowed tokens are |
| // added/removed consecutively. |
| while (unformattedHasMore && formattedHasMore) { |
| var sameTokenTypes = formattedToken.type == unformattedToken.type; |
| |
| // Handle differences allowed when tokens are different types. |
| if (!sameTokenTypes) { |
| // The formatter added an allowed token, advance over it. |
| if (_isAllowedOptional(formattedToken)) { |
| advanceFormatted(); |
| continue; |
| } |
| |
| // The formatter removed an allowed token, advance over it. |
| if (_isAllowedOptional(unformattedToken)) { |
| advanceUnformatted(); |
| continue; |
| } |
| |
| // The formatter substituted `unformattedToken` for some other tokens |
| // starting at `formattedToken`. |
| if (_substitutes(unformattedToken, formattedToken) case var num?) { |
| advanceUnformatted(); |
| advanceFormatted(num); |
| continue; |
| } |
| |
| // The formatter collapsed tokens in `unformattedToken` for a new |
| // `formattedToken`. This is the opposite of the case above. |
| if (_substitutes(formattedToken, unformattedToken) case var num?) { |
| advanceFormatted(); |
| advanceUnformatted(num); |
| continue; |
| } |
| } |
| |
| // Handle differences allowed when tokens are the same type. |
| if (sameTokenTypes) { |
| // The formatter made a change to the lexeme of a token type we allow. |
| if (allowedLexemeDifferences.contains(unformattedToken.type) && |
| unformattedToken.lexeme != formattedToken.lexeme) { |
| advanceUnformatted(); |
| advanceFormatted(); |
| allowAnyContentDifferences = true; |
| continue; |
| } |
| } |
| |
| // If we didn't hit any `continue` above to restart the loop, then we |
| // are done. |
| break; |
| } |
| |
| 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 use the |
| // fallback. |
| return _generateFallback(); |
| } |
| |
| // Add edits for the computed ranges. |
| _addEditFor( |
| unformattedStart, |
| unformattedEnd, |
| formattedStart, |
| formattedEnd, |
| allowAnyContentDifferences: allowAnyContentDifferences, |
| ); |
| |
| // 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) { |
| return _edits; |
| } |
| } |
| |
| // If we got here and either of the streams still have tokens, something |
| // did not match so use the fallback. |
| if (unformattedHasMore || formattedHasMore) { |
| return _generateFallback(); |
| } |
| |
| // Finally, handle any whitespace that was after the last token. |
| _addEditFor( |
| unformattedOffset, |
| _unformatted.length, |
| formattedOffset, |
| _formatted.length, |
| ); |
| |
| return _edits; |
| } |
| |
| /// Helper for comparing whitespace and appending an edit. |
| void _addEditFor( |
| int unformattedStart, |
| int unformattedEnd, |
| int formattedStart, |
| int formattedEnd, { |
| bool allowAnyContentDifferences = false, |
| }) { |
| var unformattedWhitespace = _unformatted.substring( |
| unformattedStart, |
| unformattedEnd, |
| ); |
| var 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 && |
| unformattedEnd > _rangeStart && |
| unformattedWhitespace.contains('\n') && |
| formattedWhitespace.contains('\n') && |
| !unformattedWhitespace.endsWith('\n')) { |
| // Find the offsets of the character after the last newlines. |
| var unformattedOffset = unformattedWhitespace.lastIndexOf('\n') + 1; |
| var 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 || unformattedEnd > _rangeEnd) { |
| return; |
| } |
| } |
| |
| if (unformattedWhitespace == 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; |
| } |
| |
| // Unless allowing any differences, validate that the replaced and |
| // replacement text only contain characters that we expected the formatter |
| // to have changed. If the change contains other characters, it's likely |
| // the token offsets used were incorrect and it's better to not modify the |
| // code than potentially corrupt it. |
| if (!allowAnyContentDifferences && |
| (!_isValidFormatterChange(oldText) || |
| !_isValidFormatterChange(newText))) { |
| return; |
| } |
| |
| // 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, |
| ), |
| ); |
| } |
| |
| /// Generates fallback results for if we are unable to minimize edits |
| /// because the token streams differ in a way that we don't expect. This may |
| /// indicate a bug in the formatter, or a bug in our assumptions about what |
| /// tokens may change between formatted/unformatted (such as brackets - see |
| /// https://github.com/Dart-Code/Dart-Code/issues/5169). |
| List<TextEdit> _generateFallback() { |
| if (_rangeStart == null && _rangeEnd == null) { |
| // If this was a full document format, we can fall back to a single edit |
| // for the whole document. |
| return generateFullEdit(_lineInfo, _unformatted, _formatted); |
| } else { |
| // If we were a range format, we are unable to reduce the edits to that |
| // range so we should format nothing. |
| return []; |
| } |
| } |
| |
| /// Whether [token] is allowed to exist only in the formatted or unformatted |
| /// code. |
| /// |
| /// For example, the formatted may add or remove commas or semicolons and we |
| /// should not consider these errors. |
| bool _isAllowedOptional(Token token) { |
| return allowedAddOrRemovedTokens.contains(token.type); |
| } |
| |
| /// 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!; |
| } |
| } |
| |
| /// A helper to check whether the token [left] has been substituted in [right] |
| /// and returns the total number of tokens it was subtituted with. |
| /// |
| /// For example, if [left] is a `GT_GT_GT` token and [right] is a sequence |
| /// starting `GT`, `GT, `GT`, returns 3. |
| /// |
| /// Returns `null` if [right] does not begin with an allowed substitution. |
| int? _substitutes(Token left, Token right) { |
| var possibleSubstitutions = allowedSubstitutions[left.type]; |
| if (possibleSubstitutions == null) return null; |
| |
| // For each possible sequence of substitutes... |
| for (var possibleSubstitution in possibleSubstitutions) { |
| var numSubtitutes = possibleSubstitution.length; |
| var match = true; |
| Token? current = right; |
| |
| // Check if the first `possibleSubstitution.length` tokens match the |
| // allowed substitution. |
| for (int i = 0; i < numSubtitutes && match; i++) { |
| if (possibleSubstitution[i] != current?.type) { |
| match = false; |
| break; |
| } |
| current = current?.next; |
| } |
| |
| if (match) { |
| return numSubtitutes; |
| } |
| } |
| |
| return null; |
| } |
| |
| /// Parse and return the first of the given Dart source, `null` if code cannot |
| /// be parsed. |
| static 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; |
| } |
| } |
| } |
| |
| class _SourceMock implements Source { |
| static final Source instance = _SourceMock(); |
| |
| @override |
| dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); |
| } |