| // 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 'dart:io'; | |
| 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:analyzer/src/test_utilities/test_code_format.dart'; | |
| import 'package:analyzer_testing/src/single_unit.dart'; | |
| import 'package:test/test.dart'; | |
| import 'package:test_reflective_loader/test_reflective_loader.dart'; | |
| import '../utils/lsp_protocol_extensions.dart'; | |
| import 'request_helpers_mixin.dart'; | |
| void main() { | |
| defineReflectiveSuite(() { | |
| defineReflectiveTests(SourceEditsTest); | |
| }); | |
| } | |
| @reflectiveTest | |
| class SourceEditsTest extends SingleUnitTest with LspEditHelpersMixin { | |
| Future<void> test_format_version_defaultsToLatest() async { | |
| // Latest version should parse and format records. | |
| const startContent = ''' | |
| var a = (1, 2); | |
| '''; | |
| const endContent = ''' | |
| var a = (1, 2); | |
| '''; | |
| const expectedEdits = r''' | |
| Delete 1:5-1:8 | |
| '''; | |
| await _assertFormatEdits(startContent, endContent, expectedEdits); | |
| } | |
| Future<void> test_format_version_languageVersionToken() async { | |
| // 2.19 will not parse/format records. | |
| const content = ''' | |
| // @dart = 2.19 | |
| var a = (1, 2); | |
| '''; | |
| await _assertNoFormatEdits(content); | |
| } | |
| Future<void> test_format_version_packageConfig() async { | |
| // 2.19 will not parse/format records. | |
| writeTestPackageConfig2(languageVersion: '2.19'); | |
| const content = ''' | |
| var a = (1, 2); | |
| '''; | |
| await _assertNoFormatEdits(content); | |
| } | |
| Future<void> test_format_version_versionToken_overridesPackageConfig() async { | |
| // 2.19 will not parse/format records. | |
| writeTestPackageConfig2(languageVersion: '3.0'); | |
| const content = ''' | |
| // @dart = 2.19 | |
| var a = (1, 2); | |
| '''; | |
| await _assertNoFormatEdits(content); | |
| } | |
| 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. | |
| var startContent = TestCode.parse(r''' | |
| /** | |
| * line with trailing whitespace /**/ | |
| * line with trailing whitespace /**/ | |
| */ | |
| int? a; | |
| ''').code; | |
| // 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_formatting_shortStyle() async { | |
| const startContent = ''' | |
| // @dart = 3.5 | |
| void f({String argument1, String argument2}) {} | |
| void g() { | |
| f(argument1: 'An argument', argument2: 'Another argument'); | |
| } | |
| '''; | |
| const endContent = ''' | |
| // @dart = 3.5 | |
| void f({String argument1, String argument2}) {} | |
| void g() { | |
| f(argument1: 'An argument', argument2: 'Another argument'); | |
| } | |
| '''; | |
| const expectedEdits = ''; | |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); | |
| } | |
| @FailingTest(issue: 'https://github.com/dart-lang/sdk/issues/56685') | |
| Future<void> test_minimalEdits_formatting_tallStyle() async { | |
| const startContent = ''' | |
| void f({String? argument1, String? argument2}) {} | |
| void g() { | |
| f(argument1: 'An argument', argument2: 'Another argument'); | |
| } | |
| '''; | |
| const endContent = ''' | |
| void f({String? argument1, String? argument2}) {} | |
| void g() { | |
| f(argument1: 'An argument', | |
| argument2: 'Another argument', | |
| ); | |
| } | |
| '''; | |
| const expectedEdits = r''' | |
| Insert "\n " at 4:31 | |
| Insert ",\n" at 4:60 | |
| '''; | |
| 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_trailingComment_trailingWhitespace() async { | |
| // The `/**/` markers are parsed out by TestCode and here only to preserve | |
| // trailing whitespace. | |
| const startContent = ''' | |
| const x = 42; // Watermelon /**/ | |
| const y = 42; | |
| '''; | |
| const endContent = ''' | |
| const x = 42; // Watermelon | |
| const y = 42; | |
| '''; | |
| const expectedEdits = r''' | |
| Replace 1:15-1:38 with "// Watermelon" | |
| '''; | |
| await _assertMinimalEdits(startContent, endContent, expectedEdits); | |
| } | |
| Future<void> | |
| test_minimalEdits_trailingComment_trailingWhitespace_atEof() async { | |
| // The `/**/` markers are parsed out by TestCode and here only to preserve | |
| // trailing whitespace. | |
| const startContent = ''' | |
| const x = 42; // Watermelon /**/ | |
| const y = 42; // Cantaloupe /**/ | |
| '''; | |
| const endContent = ''' | |
| const x = 42; // Watermelon | |
| const y = 42; // Cantaloupe | |
| '''; | |
| const expectedEdits = r''' | |
| Replace 1:15-1:38 with "// Watermelon" | |
| Replace 2:15-2:38 with "// Cantaloupe" | |
| '''; | |
| 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 generating edits to format [start] match those described | |
| /// in [expected] and when applied, result in [end]. | |
| /// | |
| /// 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> _assertFormatEdits( | |
| String start, | |
| String end, | |
| String expected, { | |
| String? expectedFormatResult, | |
| Range? range, | |
| }) async { | |
| start = normalizeSource(start); | |
| end = normalizeSource(end); | |
| await parseTestCode(start); | |
| var edits = generateEditsForFormatting( | |
| testParsedResult, | |
| range: range, | |
| ).result!; | |
| expect(edits.toText().trim(), expected.trim()); | |
| expect(applyTextEdits(start, edits), expectedFormatResult ?? end); | |
| } | |
| /// 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 = normalizeSource(start.trim()); | |
| end = normalizeSource(end.trim()); | |
| if (expectedFormatResult != null) { | |
| expectedFormatResult = normalizeSource(expectedFormatResult.trim()); | |
| } | |
| expected = expected.trim(); | |
| // Expectation descriptions are always written with literal newlines as '\n' | |
| // so on Windows we will need to update the expectation to '\r\n' before | |
| // comparing. | |
| expect( | |
| expected, | |
| isNot(contains(r'\r')), | |
| reason: | |
| 'Expectations should always be written with literal ' | |
| r"'\n' to indicate newlines regardless of platform", | |
| ); | |
| if (Platform.isWindows) { | |
| expected = expected.replaceAll(r'\n', r'\r\n'); | |
| } | |
| await parseTestCode(start); | |
| var edits = generateMinimalEdits(testParsedResult, end, range: range); | |
| expect(edits.toText().trim(), expected); | |
| expect( | |
| applyTextEdits(parsedTestCode.code, edits.result), | |
| expectedFormatResult ?? end, | |
| ); | |
| } | |
| /// Assert that formatting [content] produces no edits. | |
| Future<void> _assertNoFormatEdits(String content) async { | |
| await parseTestCode(content); | |
| var edits = generateEditsForFormatting(testParsedResult).result; | |
| expect(edits, isNull); | |
| } | |
| } | |
| /// 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. | |
| /// | |
| /// 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()); | |
| } |