| // 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 'dart:convert'; |
| |
| import 'package:analysis_server/lsp_protocol/protocol.dart'; |
| import 'package:analysis_server/src/lsp/error_or.dart'; |
| import 'package:analysis_server/src/lsp/source_edits.dart'; |
| import 'package:test/test.dart'; |
| import 'package:test_reflective_loader/test_reflective_loader.dart'; |
| |
| import '../abstract_single_unit.dart'; |
| import 'request_helpers_mixin.dart'; |
| |
| void main() { |
| defineReflectiveSuite(() { |
| defineReflectiveTests(SourceEditsTest); |
| }); |
| } |
| |
| @reflectiveTest |
| class SourceEditsTest extends AbstractSingleUnitTest with LspEditHelpersMixin { |
| Future<void> test_minimalEdits_comma_delete() async { |
| const startContent = ''' |
| void f(int a,) {} |
| '''; |
| const endContent = ''' |
| void f(int a) {} |
| '''; |
| const expectedEdits = r''' |
| Delete 1:13-1:14 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_delete_afterBlockComment() async { |
| const startContent = ''' |
| void f(int a /* before */,); |
| '''; |
| const endContent = ''' |
| void f(int a /* before */); |
| '''; |
| const expectedEdits = r''' |
| Delete 1:26-1:27 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_delete_afterWhitespace() async { |
| const startContent = ''' |
| void f(int a ,) {} |
| '''; |
| const endContent = ''' |
| void f(int a ) {} |
| '''; |
| const expectedEdits = r''' |
| Delete 1:14-1:15 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_delete_beforeWhitespace() async { |
| const startContent = ''' |
| void f(int a, ) {} |
| '''; |
| const endContent = ''' |
| void f(int a ) {} |
| '''; |
| const expectedEdits = r''' |
| Delete 1:13-1:14 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_delete_betweenBlockComments() async { |
| const startContent = ''' |
| void f(int a /* before */ , /* after */); |
| '''; |
| const endContent = ''' |
| void f(int a /* before */ /* after */); |
| '''; |
| const expectedEdits = r''' |
| Delete 1:27-1:29 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> |
| test_minimalEdits_comma_delete_betweenBlockComments_withWrapping() async { |
| const startContent = ''' |
| void f(veryLongArgument, argument /* before */ , /* after */ argument); |
| '''; |
| const endContent = ''' |
| void f( |
| veryLongArgument, |
| argument, /* before */ |
| /* after */ argument, |
| ); |
| '''; |
| const expectedEdits = r''' |
| Insert "\n " at 1:8 |
| Insert "\n " at 1:25 |
| Insert "," at 1:34 |
| Replace 1:47-1:49 with "\n " |
| Insert ",\n" at 1:70 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_delete_betweenWhitespace() async { |
| const startContent = ''' |
| void f(int a , ) {} |
| '''; |
| const endContent = ''' |
| void f(int a ) {} |
| '''; |
| const expectedEdits = r''' |
| Delete 1:14-1:15 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insert() async { |
| const startContent = ''' |
| void f(int a) {} |
| '''; |
| const endContent = ''' |
| void f(int a,) {} |
| '''; |
| const expectedEdits = r''' |
| Insert "," at 1:13 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insert_afterWhitespace() async { |
| const startContent = ''' |
| void f(int a ) {} |
| '''; |
| const endContent = ''' |
| void f(int a ,) {} |
| '''; |
| const expectedEdits = r''' |
| Insert "," at 1:14 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insert_beforeWhitespace() async { |
| const startContent = ''' |
| void f( |
| int a |
| ) {} |
| '''; |
| const endContent = ''' |
| void f( |
| int a, |
| ) {} |
| '''; |
| const expectedEdits = r''' |
| Insert "," at 2:8 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insert_betweenWhitespace() async { |
| const startContent = ''' |
| void f(int a ) {} |
| '''; |
| const endContent = ''' |
| void f(int a , ) {} |
| '''; |
| const expectedEdits = r''' |
| Insert "," at 1:14 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> |
| test_minimalEdits_comma_insertWithLeadingAndTrailingWhitespace() async { |
| const startContent = ''' |
| void f(int a) {} |
| '''; |
| const endContent = ''' |
| void f(int a , ) {} |
| '''; |
| const expectedEdits = r''' |
| Insert " , " at 1:13 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insertWithLeadingWhitespace() async { |
| const startContent = ''' |
| void f(int a) {} |
| '''; |
| const endContent = ''' |
| void f(int a ,) {} |
| '''; |
| const expectedEdits = r''' |
| Insert " ," at 1:13 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_insertWithTrailingWhitespace() async { |
| const startContent = ''' |
| void f(int a) {} |
| '''; |
| const endContent = ''' |
| void f(int a, ) {} |
| '''; |
| const expectedEdits = r''' |
| Insert ", " at 1:13 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_comma_move() async { |
| const startContent = ''' |
| void f( |
| int a // comment |
| ,) { |
| } |
| '''; |
| const endContent = ''' |
| void f( |
| int a, // comment |
| ) { |
| } |
| '''; |
| const expectedEdits = r''' |
| Insert "," at 2:8 |
| Delete 3:1-3:2 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_commaAndSemicolon_remove() async { |
| const startContent = ''' |
| enum SomeEnum { a, b, c,; } |
| '''; |
| const endContent = ''' |
| enum SomeEnum { a, b, c } |
| '''; |
| const expectedEdits = r''' |
| Delete 1:24-1:26 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| /// The formatter removes trailing whitespace from comments which results in |
| /// differences in the comment token lexemes. This should |
| /// be handled and not result in a full document edit. |
| /// |
| /// https://github.com/Dart-Code/Dart-Code/issues/5200 |
| Future<void> test_minimalEdits_comment_multiLine_trailingWhitespace() async { |
| // The initial content has a trailing space on the end of the comment. |
| const startContent = r''' |
| /** |
| * line with trailing whitespace |
| * line with trailing whitespace |
| */ |
| int? a; |
| '''; |
| // We expect the trailing spaces to be removed. |
| const endContent = r''' |
| /** |
| * line with trailing whitespace |
| * line with trailing whitespace |
| */ |
| int? a; |
| '''; |
| |
| // Expect the edit to replace the entire comment, minus the common |
| // prefix/suffix. That is, it will replace from the end of the first line |
| // of the comment (consuming the trailing whitespace) up up until the |
| // trailing space of the second line. |
| // We do not support minimizing the edits within the comment itself, because |
| // we only diff tokens and not the string contents. |
| const expectedEdits = r''' |
| Replace 2:33-3:34 with "\n * line with trailing whitespace" |
| '''; |
| |
| await _assertMinimalEdits( |
| startContent, |
| endContent, |
| expectedEdits, |
| ); |
| } |
| |
| /// The formatter removes trailing whitespace from comments which results in |
| /// differences in the comment token lexemes. This should |
| /// be handled and not result in a full document edit. |
| /// |
| /// https://github.com/Dart-Code/Dart-Code/issues/5200 |
| Future<void> test_minimalEdits_comment_singleLine_trailingWhitespace() async { |
| // The initial content has a trailing space on the end of the comment. |
| const startContent = 'const a = 1; // a \nconst b = 2;'; |
| // We expect the trailing space will be removed. |
| const endContent = 'const a = 1; // a\nconst b = 2;'; |
| |
| // Expect the edit to be only the deletion of that one character, not a |
| // full edit (or full replacement of the comment). |
| const expectedEdits = r''' |
| Delete 1:18-1:19 |
| '''; |
| |
| await _assertMinimalEdits( |
| startContent, |
| endContent, |
| expectedEdits, |
| ); |
| } |
| |
| /// Empty collections that are unwrapped produce different tokens. This should |
| /// be handled and not result in a full document edit. |
| /// |
| /// https://github.com/Dart-Code/Dart-Code/issues/5169 |
| Future<void> test_minimalEdits_emptyCollection() async { |
| const startContent = ''' |
| var a = <String>[ |
| ]; |
| var b = ''; |
| '''; |
| const endContent = ''' |
| var a = <String>[]; |
| var b = ''; |
| '''; |
| // Expect the newline to be deleted. |
| const expectedEdits = r''' |
| Delete 1:18-2:1 |
| '''; |
| |
| await _assertMinimalEdits( |
| startContent, |
| endContent, |
| expectedEdits, |
| ); |
| } |
| |
| Future<void> test_minimalEdits_gt_2_combined() async { |
| const startContent = ''' |
| List< |
| List<String> |
| > a = []; |
| '''; |
| const endContent = ''' |
| List<List<String>> a = []; |
| '''; |
| const expectedEdits = r''' |
| Delete 1:6-2:3 |
| Delete 2:15-3:1 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_gt_2_split() async { |
| const startContent = ''' |
| List<List<String>> a = []; |
| '''; |
| const endContent = ''' |
| List< |
| List<String> |
| > a = []; |
| '''; |
| const expectedEdits = r''' |
| Insert "\n " at 1:6 |
| Insert "\n" at 1:18 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_gt_3_combined() async { |
| const startContent = ''' |
| List< |
| List< |
| List<String> |
| > |
| > a = []; |
| '''; |
| const endContent = ''' |
| List<List<List<String>>> a = []; |
| '''; |
| const expectedEdits = r''' |
| Delete 1:6-2:3 |
| Delete 2:8-3:5 |
| Replace 3:17-5:1 with ">" |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_gt_3_split() async { |
| const startContent = ''' |
| List<List<List<String>>> a = []; |
| '''; |
| const endContent = ''' |
| List< |
| List< |
| List<String> |
| > |
| > a = []; |
| '''; |
| const expectedEdits = r''' |
| Insert "\n " at 1:6 |
| Insert "\n " at 1:11 |
| Replace 1:23-1:24 with "\n >\n" |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_semicolon_remove() async { |
| const startContent = ''' |
| enum SomeEnum { |
| a, |
| b, |
| c; |
| } |
| '''; |
| const endContent = ''' |
| enum SomeEnum { |
| a, |
| b, |
| c |
| } |
| '''; |
| const expectedEdits = r''' |
| Delete 4:4-4:5 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| Future<void> test_minimalEdits_whitespace() async { |
| const startContent = ''' |
| void f(){} |
| '''; |
| const endContent = ''' |
| void f() { |
| } |
| '''; |
| const expectedEdits = r''' |
| Delete 1:6-1:8 |
| Insert " " at 1:11 |
| Insert "\n" at 1:12 |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| /// If we fail to compute minimal edits but were formatting the entire doc, |
| /// we should just return the entire edit. |
| Future<void> test_minimalEdits_withoutRange_wholeDocument() async { |
| const startContent = ''' |
| void f() { |
| } |
| '''; |
| // The simulated formatted content is different (`g` instead of `f`) which |
| // will fail to produce minimal edits (since it varies by more than |
| // whitespace). |
| const endContent = ''' |
| void g() { |
| } |
| '''; |
| const expectedEdits = r''' |
| Replace 1:1-2:2 with "void g() {\n}" |
| '''; |
| |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); |
| } |
| |
| /// If we fail to compute minimal edits but were formatting a range, |
| /// we should return no edits (rather than the entire document). |
| /// |
| /// https://github.com/Dart-Code/Dart-Code/issues/5169 |
| Future<void> test_minimalEdits_withRange_emptyResult() async { |
| const startContent = ''' |
| void f() { |
| } |
| '''; |
| // The simulated formatted content is different (`g` instead of `f`) which |
| // will fail to produce minimal edits (since it varies by more than |
| // whitespace). |
| const endContent = ''' |
| void g() { |
| } |
| '''; |
| const expectedEdits = r''; // No edits. |
| |
| await _assertMinimalEdits( |
| startContent, |
| endContent, |
| expectedEdits, |
| range: Range( |
| start: Position(line: 1, character: 1), |
| end: Position(line: 1, character: 1), |
| ), |
| // We should end with the original content because there was no |
| // formatting. |
| expectedFormatResult: startContent, |
| ); |
| } |
| |
| /// Assert that computing minimal edits to convert [start] to [end] produces |
| /// the set of edits described in [expected]. |
| /// |
| /// Edits will be automatically applied and verified. [expected] is to ensure |
| /// the edits are minimal and we didn't accidentally produces a single edit |
| /// replacing the entire file. |
| Future<void> _assertMinimalEdits( |
| String start, |
| String end, |
| String expected, { |
| String? expectedFormatResult, |
| Range? range, |
| }) async { |
| start = start.trim(); |
| end = end.trim(); |
| expected = expected.trim(); |
| expectedFormatResult = expectedFormatResult?.trim(); |
| |
| await parseTestCode(start); |
| var edits = generateMinimalEdits(testParsedResult, end, range: range); |
| expect(edits.toText().trim(), expected); |
| expect(applyTextEdits(start, edits.result), expectedFormatResult ?? end); |
| } |
| } |
| |
| /// Helpers for building simple text representations of edits to verify that |
| /// minimal diffs were produced. |
| /// |
| /// Does not include actual content - resulting content should be verified |
| /// separately. |
| extension on List<TextEdit> { |
| String toText() => map((edit) => edit.toText()).join('\n'); |
| } |
| |
| /// Helpers for building simple text representations of edits to verify that |
| /// minimal diffs were produced. |
| /// |
| /// Does not include actual content - resulting content should be verified |
| /// separately. |
| extension on TextEdit { |
| String toText() { |
| return range.start == range.end |
| ? 'Insert ${jsonEncode(newText)} at ${range.start.toText()}' |
| : newText.isEmpty |
| ? 'Delete ${range.toText()}' |
| : 'Replace ${range.toText()} with ${jsonEncode(newText)}'; |
| } |
| } |
| |
| /// Helpers for building simple text representations of edits to verify that |
| /// minimal diffs were produced. |
| extension on Range { |
| String toText() => '${start.toText()}-${end.toText()}'; |
| } |
| |
| /// Helpers for building simple text representations of edits to verify that |
| /// minimal diffs were produced. |
| extension on Position { |
| String toText() => '${line + 1}:${character + 1}'; |
| } |
| |
| /// Helpers for building simple text representations of edits to verify that |
| /// minimal diffs were produced. |
| /// |
| /// Does not include actual content - resulting content should be verified |
| /// separately. |
| extension on ErrorOr<List<TextEdit>> { |
| String toText() => map( |
| (error) => 'Error: ${error.message}', |
| (result) => result.toText(), |
| ); |
| } |