Skip to content

Commit 8473a6e

Browse files
DanTupCommit Queue
authored andcommitted
[analysis_server] Prevent LSP format-on-type from formatting in unwanted cases
We register `}` and `;` as trigger characters for format-on-type, but some instances of these characters should not trigger formatting (for example in strings and comments). With this change, we'll look at the AST and only format for specific cases we've opted in to, instead of all. Fixes Dart-Code/Dart-Code#5832 Change-Id: I94d348520649ea2e072f13350863f27a2e07699d Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/466100 Reviewed-by: Brian Wilkerson <[email protected]> Reviewed-by: Samuel Rawlins <[email protected]> Commit-Queue: Brian Wilkerson <[email protected]>
1 parent 45ec012 commit 8473a6e

File tree

4 files changed

+219
-12
lines changed

4 files changed

+219
-12
lines changed

pkg/analysis_server/lib/src/lsp/constants.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
/// @docImport 'package:analysis_server/src/lsp/handlers/handler_format_on_type.dart';
6+
library;
7+
58
import 'package:analysis_server/lsp_protocol/protocol.dart';
69
import 'package:analysis_server/src/services/refactoring/framework/refactoring_processor.dart';
710

@@ -43,6 +46,9 @@ const dartSignatureHelpRetriggerCharacters = <String>[','];
4346
const dartSignatureHelpTriggerCharacters = <String>['('];
4447

4548
/// Characters to trigger formatting when format-on-type is enabled.
49+
///
50+
/// [FormatOnTypeHandler._shouldTriggerFormatting] contains the logic for when
51+
/// each of these characters will allow formatting.
4652
const dartTypeFormattingCharacters = ['}', ';'];
4753

4854
/// A [TextDocumentFilterScheme] for Analysis Options files.

pkg/analysis_server/lib/src/lsp/handlers/handler_format_on_type.dart

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import 'package:analysis_server/src/lsp/handlers/handlers.dart';
99
import 'package:analysis_server/src/lsp/mapping.dart';
1010
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';
1111
import 'package:analysis_server/src/lsp/source_edits.dart';
12+
import 'package:analyzer/dart/analysis/results.dart';
13+
import 'package:analyzer/dart/ast/ast.dart';
14+
import 'package:analyzer/dart/ast/token.dart';
1215

1316
typedef StaticOptions = DocumentOnTypeFormattingOptions?;
1417

@@ -27,7 +30,11 @@ class FormatOnTypeHandler
2730
@override
2831
bool get requiresTrustedCaller => false;
2932

30-
Future<ErrorOr<List<TextEdit>?>> formatFile(String path) async {
33+
Future<ErrorOr<List<TextEdit>?>> formatFile(
34+
String path,
35+
Position typedCharacterPosition,
36+
String typedCharacter,
37+
) async {
3138
var file = server.resourceProvider.getFile(path);
3239
if (!file.exists) {
3340
return error(
@@ -42,6 +49,18 @@ class FormatOnTypeHandler
4249
return success(null);
4350
}
4451

52+
// The client will always send a request when a trigger character is typed
53+
// even if it's in a comment or something else that shouldn't trigger
54+
// formatting so we need to check the character/position to see if this is
55+
// really something that should be allowed to trigger formatting.
56+
if (!_shouldTriggerFormatting(
57+
result,
58+
typedCharacterPosition,
59+
typedCharacter,
60+
)) {
61+
return success(null);
62+
}
63+
4564
var lineLength = server.lspClientConfiguration.forResource(path).lineLength;
4665
return generateEditsForFormatting(result, defaultPageWidth: lineLength);
4766
}
@@ -64,9 +83,80 @@ class FormatOnTypeHandler
6483
return success(null);
6584
}
6685

67-
return await formatFile(path);
86+
return await formatFile(path, params.position, params.ch);
6887
});
6988
}
89+
90+
/// Returns whether [character] at [position] should trigger formatting.
91+
///
92+
/// Generally we only want to trigger formatting for things like a `;` at the
93+
/// end of a statement, and not inside something like a string or comment.
94+
///
95+
/// Trigger characters are declared in [dartTypeFormattingCharacters].
96+
bool _shouldTriggerFormatting(
97+
ParsedUnitResult result,
98+
Position position,
99+
String character,
100+
) {
101+
var offset =
102+
result.lineInfo.getOffsetOfLine(position.line) + position.character;
103+
var node = result.unit.nodeCovering(offset: offset);
104+
105+
/// Helper to check if a token is at the current offset.
106+
///
107+
/// Check both offset/end because the LSP spec says
108+
/// >> This is not necessarily the exact position where the character
109+
/// >> denoted by the property `ch` got typed.
110+
/// and in testing with VS Code it's the end.
111+
bool isAtOffset(Token token) =>
112+
token.offset == offset || token.end == offset;
113+
114+
return switch (character) {
115+
// Only consider semicolons that are the end of statements/declarations
116+
';' => switch (node) {
117+
// Statements
118+
AssertStatement(:var semicolon) ||
119+
BreakStatement(:var semicolon) ||
120+
ContinueStatement(:var semicolon) ||
121+
DoStatement(:var semicolon) ||
122+
EmptyStatement(:var semicolon) ||
123+
ExpressionStatement(:var semicolon?) ||
124+
PatternVariableDeclarationStatement(:var semicolon) ||
125+
ReturnStatement(:var semicolon) ||
126+
VariableDeclarationStatement(:var semicolon) ||
127+
YieldStatement(:var semicolon) ||
128+
// Bodies
129+
EmptyClassBody(:var semicolon) ||
130+
EmptyFunctionBody(:var semicolon) ||
131+
EnumBody(:var semicolon?) ||
132+
ExpressionFunctionBody(:var semicolon?) ||
133+
NativeFunctionBody(:var semicolon) ||
134+
// Declarations
135+
FieldDeclaration(:var semicolon) ||
136+
TopLevelVariableDeclaration(:var semicolon) ||
137+
TypeAlias(:var semicolon) ||
138+
// Directives
139+
LibraryDirective(:var semicolon) ||
140+
NamespaceDirective(:var semicolon) ||
141+
PartDirective(:var semicolon) ||
142+
PartOfDirective(:var semicolon) => isAtOffset(semicolon),
143+
_ => false,
144+
},
145+
// Only consider closing braces that are the end of "blocks" but not
146+
// things like patterns that might usually be inline.
147+
'}' => switch (node) {
148+
Block(:var rightBracket) ||
149+
BlockClassBody(:var rightBracket) ||
150+
EnumBody(:var rightBracket) ||
151+
ListLiteral(:var rightBracket) ||
152+
SetOrMapLiteral(:var rightBracket) ||
153+
SwitchExpression(:var rightBracket) ||
154+
SwitchStatement(:var rightBracket) => isAtOffset(rightBracket),
155+
_ => false,
156+
},
157+
_ => false,
158+
};
159+
}
70160
}
71161

72162
class FormatOnTypeRegistrations extends FeatureRegistration

pkg/analysis_server/test/lsp/format_test.dart

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,46 @@ class FormatTest extends AbstractLspAnalysisServerTest {
2727
static const codeThatWrapsAt40 =
2828
"var a = ' 10 20 30 40';";
2929

30+
/// Verifies the result of a format-on-type request for the character in the
31+
/// marked range in [content].
32+
///
33+
/// If the marked range has a position, it will be used for the request,
34+
/// otherwise the end point of the range will be used.
35+
Future<List<TextEdit>?> expectFormatOnTypeResult(
36+
String content,
37+
Matcher matcher,
38+
) async {
39+
var code = TestCode.parseNormalized(content);
40+
41+
// We use range to make it really explicit which character is typed and not
42+
// have to worry about whether it's before or after the ^.
43+
expect(code.ranges, hasLength(1));
44+
expect(code.positions, hasLength(anyOf(0, 1)));
45+
46+
var typedCharacter = code.code.substring(
47+
code.range.sourceRange.offset,
48+
code.range.sourceRange.end,
49+
);
50+
await initialize();
51+
await openFile(mainFileUri, code.code);
52+
53+
var result = await formatOnType(
54+
mainFileUri,
55+
code.positions.isNotEmpty ? code.position.position : code.range.range.end,
56+
typedCharacter,
57+
);
58+
59+
expect(
60+
result,
61+
matcher,
62+
reason:
63+
'Typing the character "$typedCharacter" at the marked location '
64+
'should match the supplied matcher',
65+
);
66+
67+
return result;
68+
}
69+
3070
Future<List<TextEdit>> expectFormattedContents(
3171
Uri uri,
3272
String original,
@@ -193,30 +233,100 @@ ErrorOr<Pair<A, List<B>>> c(String d, List<Either2<E, F>> g, {h = false}) {}
193233
expect(registration(Method.textDocument_rangeFormatting), isNotNull);
194234
}
195235

196-
Future<void> test_formatOnType_simple() async {
236+
Future<void> test_formatOnType_bad_brace_inComment() async {
237+
await expectFormatOnTypeResult('''
238+
void f ()
239+
{
240+
// hello[!}!]
241+
print('test');
242+
}
243+
''', isNull);
244+
}
245+
246+
Future<void> test_formatOnType_bad_brace_inString() async {
247+
await expectFormatOnTypeResult('''
248+
void f ()
249+
{
250+
251+
print('te[!}!]st');
252+
}
253+
''', isNull);
254+
}
255+
256+
Future<void> test_formatOnType_bad_semicolon_inComment() async {
257+
await expectFormatOnTypeResult('''
258+
void f ()
259+
{
260+
// hello[!;!]
261+
print('test');
262+
}
263+
''', isNull);
264+
}
265+
266+
Future<void> test_formatOnType_bad_semicolon_inString() async {
267+
await expectFormatOnTypeResult('''
268+
void f ()
269+
{
270+
271+
print('te[!;!]st');
272+
}
273+
''', isNull);
274+
}
275+
276+
Future<void> test_formatOnType_good_brace_blockEnd() async {
277+
await expectFormatOnTypeResult('''
278+
void f ()
279+
{
280+
print('test');
281+
[!}!]
282+
''', isNotNull);
283+
}
284+
285+
Future<void> test_formatOnType_good_formatsCode() async {
197286
var code = TestCode.parseNormalized('''
198287
void f ()
199288
{
200289
201290
print('test');
202-
^}
291+
[!}!]
203292
''');
204293
var expected = '''void f() {
205294
print('test');
206295
}
207296
''';
208-
await initialize();
209-
await openFile(mainFileUri, code.code);
210-
211-
var formatEdits = (await formatOnType(
212-
mainFileUri,
213-
code.position.position,
214-
'}',
297+
var formatEdits = (await expectFormatOnTypeResult(
298+
code.markedCode,
299+
isNotNull,
215300
))!;
216301
var formattedContents = applyTextEdits(code.code, formatEdits);
217302
expect(formattedContents, equalsNormalized(expected));
218303
}
219304

305+
Future<void> test_formatOnType_good_positionAtEnd() async {
306+
await expectFormatOnTypeResult('''
307+
void f ()
308+
{
309+
[!}^!]
310+
''', isNotNull);
311+
}
312+
313+
Future<void> test_formatOnType_good_positionAtOffset() async {
314+
await expectFormatOnTypeResult('''
315+
void f ()
316+
{
317+
[!^}!]
318+
''', isNotNull);
319+
}
320+
321+
Future<void> test_formatOnType_good_semicolon_statementEnd() async {
322+
await expectFormatOnTypeResult('''
323+
void f ()
324+
{
325+
print('test')[!;!]
326+
}
327+
''', isNotNull);
328+
}
329+
220330
Future<void> test_formatRange_editsOverlapRange() async {
221331
// Only ranges that are fully contained by the range should be applied,
222332
// not those that intersect the start/end.

pkg/analysis_server/test/lsp_over_legacy/format_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class FormatTest extends LspOverLegacyTest {
3232
newFile(testFilePath, content);
3333
await waitForTasksFinished();
3434

35-
var edits = await formatOnType(testFileUri, startOfDocPos, '}');
35+
var position = positionFromOffset(content.indexOf('}'), content);
36+
var edits = await formatOnType(testFileUri, position, '}');
3637
var formattedContents = applyTextEdits(content, edits!);
3738
expect(formattedContents.trimRight(), equals(expectedContent));
3839
}

0 commit comments

Comments
 (0)