[analysis_server] Refactor LSP snippet building in preparation for Snippet completions Change-Id: Ie39e2e803d17435b31246da07ed69eba7d7aba21 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/233360 Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart index ba6a1d6..817937e 100644 --- a/pkg/analysis_server/lib/src/lsp/mapping.dart +++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -18,6 +18,7 @@ import 'package:analysis_server/src/lsp/constants.dart'; import 'package:analysis_server/src/lsp/dartdoc.dart'; import 'package:analysis_server/src/lsp/lsp_analysis_server.dart' as lsp; +import 'package:analysis_server/src/lsp/snippets.dart'; import 'package:analysis_server/src/lsp/source_edits.dart'; import 'package:analysis_server/src/protocol_server.dart' as server hide AnalysisError; @@ -68,50 +69,6 @@ _asMarkup(preferredFormats, content)); } -/// Builds an LSP snippet string with supplied ranges as tabstops. -String buildSnippetStringWithTabStops( - String? text, - List<int>? offsetLengthPairs, -) { - text ??= ''; - offsetLengthPairs ??= const []; - - // Snippets syntax is documented in the LSP spec: - // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax - // - // $1, $2, etc. are used for tab stops and ${1:foo} inserts a placeholder of foo. - - final output = []; - var offset = 0; - - // When there's only a single tabstop, it should be ${0} as this is treated - // specially as the final cursor position (if we use 1, the editor will insert - // a 0 at the end of the string which is not what we expect). - // When there are multiple, start with ${1} since these are placeholders the - // user can tab through and the editor-inserted ${0} at the end is expected. - var tabStopNumber = offsetLengthPairs.length <= 2 ? 0 : 1; - - for (var i = 0; i < offsetLengthPairs.length; i += 2) { - final pairOffset = offsetLengthPairs[i]; - final pairLength = offsetLengthPairs[i + 1]; - - // Add any text that came before this tabstop to the result. - output.add(escapeSnippetString(text.substring(offset, pairOffset))); - - // Add this tabstop - final tabStopText = escapeSnippetString( - text.substring(pairOffset, pairOffset + pairLength)); - output.add('\${${tabStopNumber++}:$tabStopText}'); - - offset = pairOffset + pairLength; - } - - // Add any remaining text that was after the last tabstop. - output.add(escapeSnippetString(text.substring(offset))); - - return output.join(''); -} - /// Creates a [lsp.WorkspaceEdit] from simple [server.SourceFileEdit]s. /// /// Note: This code will fetch the version of each document being modified so @@ -573,15 +530,6 @@ .firstWhere(isSupported, orElse: () => lsp.SymbolKind.Obj); } -/// Escapes a string to be used in an LSP edit that uses Snippet mode. -/// -/// Snippets can contain special markup like `${a:b}` so some characters need -/// escaping (according to the LSP spec, those are `$`, `}` and `\`). -String escapeSnippetString(String input) => input.replaceAllMapped( - RegExp(r'[$}\\]'), // Replace any of $ } \ - (c) => '\\${c[0]}', // Prefix with a backslash - ); - String? getCompletionDetail( server.CompletionSuggestion suggestion, lsp.CompletionItemKind? completionKind, @@ -942,6 +890,25 @@ ); } +/// Creates a SnippetTextEdit for an edit with a selection placeholder. +/// +/// [selectionOffset] is relative to (and therefore must be within) the edit. +lsp.SnippetTextEdit snippetTextEditWithSelection( + server.LineInfo lineInfo, + server.SourceEdit edit, { + required int selectionOffsetRelative, + int? selectionLength, +}) { + return lsp.SnippetTextEdit( + insertTextFormat: lsp.InsertTextFormat.Snippet, + range: toRange(lineInfo, edit.offset, edit.length), + newText: buildSnippetStringWithTabStops( + edit.replacement, + [selectionOffsetRelative, selectionLength ?? 0], + ), + ); +} + lsp.CompletionItemKind? suggestionKindToCompletionItemKind( Set<lsp.CompletionItemKind> supportedCompletionKinds, server.CompletionSuggestionKind kind, @@ -1405,20 +1372,6 @@ ); } -lsp.SnippetTextEdit toSnippetTextEdit( - LspClientCapabilities capabilities, - server.LineInfo lineInfo, - server.SourceEdit edit, - int selectionOffsetRelative, - int? selectionLength) { - return lsp.SnippetTextEdit( - insertTextFormat: lsp.InsertTextFormat.Snippet, - range: toRange(lineInfo, edit.offset, edit.length), - newText: buildSnippetStringWithTabStops( - edit.replacement, [selectionOffsetRelative, selectionLength ?? 0]), - ); -} - ErrorOr<server.SourceRange> toSourceRange( server.LineInfo lineInfo, Range range) { // If there is a range, convert to offsets because that's what @@ -1467,8 +1420,9 @@ toTextEdit(lineInfo, edit)); } return Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>.t1( - toSnippetTextEdit(capabilities, lineInfo, edit, selectionOffsetRelative, - selectionLength)); + snippetTextEditWithSelection(lineInfo, edit, + selectionOffsetRelative: selectionOffsetRelative, + selectionLength: selectionLength)); } lsp.TextEdit toTextEdit(server.LineInfo lineInfo, server.SourceEdit edit) { @@ -1580,21 +1534,20 @@ insertTextFormat = lsp.InsertTextFormat.Snippet; final hasRequiredParameters = (defaultArgumentListTextRanges?.length ?? 0) > 0; - final functionCallSuffix = hasRequiredParameters - ? buildSnippetStringWithTabStops( - defaultArgumentListString, - defaultArgumentListTextRanges, - ) - : '\${0:}'; // No required params still gets a tabstop in the parens. - insertText = '${escapeSnippetString(insertText)}($functionCallSuffix)'; + final functionCallSuffix = + hasRequiredParameters && defaultArgumentListString != null + ? buildSnippetStringWithTabStops( + defaultArgumentListString, defaultArgumentListTextRanges) + // No required params still gets a final tab stop in the parens. + : SnippetBuilder.finalTabStop; + insertText = + '${SnippetBuilder.escapeSnippetPlainText(insertText)}($functionCallSuffix)'; } else if (selectionOffset != 0 && - // We don't need a tabstop if the selection is the end of the string. + // We don't need a tab stop if the selection is the end of the string. selectionOffset != completion.length) { insertTextFormat = lsp.InsertTextFormat.Snippet; insertText = buildSnippetStringWithTabStops( - completion, - [selectionOffset, selectionLength], - ); + completion, [selectionOffset, selectionLength]); } }
diff --git a/pkg/analysis_server/lib/src/lsp/snippets.dart b/pkg/analysis_server/lib/src/lsp/snippets.dart new file mode 100644 index 0000000..6f59d8e --- /dev/null +++ b/pkg/analysis_server/lib/src/lsp/snippets.dart
@@ -0,0 +1,241 @@ +// Copyright (c) 2022, 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:math' as math; + +import 'package:collection/collection.dart'; + +/// Builds an LSP snippet string with supplied ranges as tab stops. +/// +/// [tabStopOffsetLengthPairs] are relative to the supplied text. +String buildSnippetStringWithTabStops( + String text, + List<int>? tabStopOffsetLengthPairs, +) { + tabStopOffsetLengthPairs ??= const []; + assert(tabStopOffsetLengthPairs.length % 2 == 0); + + // Convert selection/tab stops/edit groups all into a common format + // (`SnippetPlaceholder`) so they can be handled in a single pass through + // the text. + final placeholders = [ + // Tab stops. + for (var i = 0; i < tabStopOffsetLengthPairs.length - 1; i += 2) + SnippetPlaceholder( + tabStopOffsetLengthPairs[i], + tabStopOffsetLengthPairs[i + 1], + // If there's only a single tab stop, mark + // it as the final stop so it exit "snippet mode" when tabbed to. + isFinal: tabStopOffsetLengthPairs.length == 2, + ), + // TODO(dantup): Add edit group/selection support. + ]; + + // Remove any groups outside of the range (it's possible the edit groups apply + // to a different edit in the collection). + placeholders.removeWhere((placeholder) => + placeholder.offset < 0 || + placeholder.offset + placeholder.length > text.length); + + final builder = SnippetBuilder()..appendPlaceholders(text, placeholders); + return builder.value; +} + +/// A helper for building for snippets using LSP/TextMate syntax. +/// +/// https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax +/// +/// - $1, $2, etc. are used for tab stops +/// - ${1:foo} inserts a placeholder of foo +/// - ${1|foo,bar|} inserts a placeholder of foo with a selection list +/// containing "foo" and "bar" +class SnippetBuilder { + /// The constant `$0` used do indicate a final tab stop in the snippet syntax. + static const finalTabStop = r'$0'; + + final _buffer = StringBuffer(); + + var _nextPlaceholder = 1; + + /// The built snippet text using the LSP snippet syntax. + String get value => _buffer.toString(); + + /// Appends a placeholder with a set of choices to choose from. + /// + /// If there are 0 or 1 choices, a placeholder will be inserted instead. + /// + /// Returns the placeholder number used. + int appendChoice(Iterable<String> choices, {int? placeholderNumber}) { + final uniqueChoices = choices.where((item) => item.isNotEmpty).toSet(); + + // If there's only 0/1 items, we can downgrade this to a placeholder. + if (uniqueChoices.length <= 1) { + return appendPlaceholder( + uniqueChoices.firstOrNull ?? '', + placeholderNumber: placeholderNumber, + ); + } + + placeholderNumber = _usePlaceholerNumber(placeholderNumber); + + final escapedChoices = uniqueChoices.map(escapeSnippetChoiceText).join(','); + _buffer.write('\${$placeholderNumber|$escapedChoices|}'); + + return placeholderNumber; + } + + /// Appends a placeholder with the given text. + /// + /// If the text is empty, inserts a tab stop instead. + /// + /// Returns the placeholder number used. + int appendPlaceholder(String text, {int? placeholderNumber}) { + // If there's no text, we can downgrade this to a tab stop. + if (text.isEmpty) { + return appendTabStop(placeholderNumber: placeholderNumber); + } + + placeholderNumber = _usePlaceholerNumber(placeholderNumber); + + final escapedText = escapeSnippetVariableText(text); + _buffer.write('\${$placeholderNumber:$escapedText}'); + + return placeholderNumber; + } + + /// Appends a tab stop. + /// + /// Returns the placeholder number used. + int appendTabStop({int? placeholderNumber}) { + placeholderNumber = _usePlaceholerNumber(placeholderNumber); + + _buffer.write('\$$placeholderNumber'); + + return placeholderNumber; + } + + /// Appends normal text (escaping it as required). + void appendText(String text) { + _buffer.write(escapeSnippetPlainText(text)); + } + + /// Generates the current and next placeholder numbers. + int _usePlaceholerNumber(int? placeholderNumber) { + // If a number was not supplied, use thenext available one. + placeholderNumber ??= _nextPlaceholder; + // If the number we used was the highest seen, set the next one after it. + _nextPlaceholder = math.max(_nextPlaceholder, placeholderNumber + 1); + + return placeholderNumber; + } + + /// Escapes a string use inside a "choice" in a snippet. + /// + /// Similar to [escapeSnippetPlainText], but choices are delimited/separated + /// by pipes and commas (`${1:|a,b,c|}`). + static String escapeSnippetChoiceText(String input) => _escapeCharacters( + input, + RegExp(r'[$}\\\|,]'), // Replace any of $ } \ | , + ); + + /// Escapes a string to be used in an LSP edit that uses Snippet mode where the + /// text is outside of a snippet token. + /// + /// Snippets can contain special markup like `${a:b}` so `$` needs escaping + /// as does `\` so it's not interpreted as an escape. + static String escapeSnippetPlainText(String input) => _escapeCharacters( + input, + RegExp(r'[$\\]'), // Replace any of $ \ + ); + + /// Escapes a string to be used inside a snippet token. + /// + /// Similar to [escapeSnippetPlainText] but additionally escapes `}` so that the + /// token is not ended early if the included text contains braces. + static String escapeSnippetVariableText(String input) => _escapeCharacters( + input, + RegExp(r'[$}\\]'), // Replace any of $ } \ + ); + + /// Escapes [pattern] in [input] with backslashes. + static String _escapeCharacters(String input, Pattern pattern) => + input.replaceAllMapped(pattern, (c) => '\\${c[0]}'); +} + +/// Information about an individual placeholder/tab stop in a piece of code. +/// +/// Each placeholder represents a single position into the code, so a linked +/// edit group with 2 positions will be represented as two instances of this +/// class (with the same [linkedGroupId]). +class SnippetPlaceholder { + final int offset; + final int length; + final List<String>? suggestions; + final int? linkedGroupId; + final bool isFinal; + + SnippetPlaceholder( + this.offset, + this.length, { + this.suggestions, + this.linkedGroupId, + this.isFinal = false, + }); +} + +/// Helpers for [SnippetBuilder] that do not relate to building the main snippet +/// syntax (for example, converting from intermediate structures). +extension SnippetBuilderExtensions on SnippetBuilder { + void appendPlaceholders(String text, List<SnippetPlaceholder> placeholders) { + // Ensure placeholders are in the order they're visible in the source so + // tabbing through them doesn't appear to jump around. + placeholders.sortBy<num>((placeholder) => placeholder.offset); + + // We need to use the same placeholder number for all placeholders in the + // same linked group, so the first time we see a linked item, store its + // placeholder number here, so subsequent placeholders for the same linked + // group can reuse it. + final placeholderIdForLinkedGroupId = <int, int>{}; + + var offset = 0; + for (final placeholder in placeholders) { + // Add any text that came before this placeholder to the result. + appendText(text.substring(offset, placeholder.offset)); + + final linkedGroupId = placeholder.linkedGroupId; + int? thisPaceholderNumber; + // Override the placeholder number if it's the final one (0) or needs to + // re-use an existing one for a linked group. + if (placeholder.isFinal) { + thisPaceholderNumber = 0; + } else if (linkedGroupId != null) { + thisPaceholderNumber = placeholderIdForLinkedGroupId[linkedGroupId]; + } + + // Append the placeholder/choices. + final placeholderText = text.substring( + placeholder.offset, + placeholder.offset + placeholder.length, + ); + // appendChoice handles mapping empty/single suggestions to a normal + // placeholder. + thisPaceholderNumber = appendChoice( + [placeholderText, ...?placeholder.suggestions], + placeholderNumber: thisPaceholderNumber, + ); + + // Track where we're up to. + offset = placeholder.offset + placeholder.length; + + // Store the placeholder number used for linked groups so it can be reused + // by subsequent references to it. + if (linkedGroupId != null) { + placeholderIdForLinkedGroupId[linkedGroupId] = thisPaceholderNumber; + } + } + + // Add any remaining text that was after the last placeholder. + appendText(text.substring(offset)); + } +}
diff --git a/pkg/analysis_server/test/lsp/completion.dart b/pkg/analysis_server/test/lsp/completion.dart index 6d70a26..ea8d26b 100644 --- a/pkg/analysis_server/test/lsp/completion.dart +++ b/pkg/analysis_server/test/lsp/completion.dart
@@ -101,10 +101,10 @@ return null; } - /// Replaces the LSP snippet placeholder '${0:}' with '^' for easier verifying + /// Replaces the LSP snippet placeholder '$0' with '^' for easier verifying /// of the cursor position in completions. String withCaret(String contents, InsertTextFormat? format) => format == InsertTextFormat.Snippet - ? contents.replaceFirst(r'${0:}', '^') + ? contents.replaceFirst(r'$0', '^') : contents; }
diff --git a/pkg/analysis_server/test/lsp/completion_dart_test.dart b/pkg/analysis_server/test/lsp/completion_dart_test.dart index 01f2b09..1f749a6 100644 --- a/pkg/analysis_server/test/lsp/completion_dart_test.dart +++ b/pkg/analysis_server/test/lsp/completion_dart_test.dart
@@ -354,7 +354,7 @@ // Ensure the snippet comes through in the expected format with the expected // placeholders. expect(item.insertTextFormat, equals(InsertTextFormat.Snippet)); - expect(item.insertText, equals('setState(() {\n \${0:}\n \\});')); + expect(item.insertText, equals('setState(() {\n \$0\n });')); final textEdit = toTextEdit(item.textEdit!); expect(textEdit.newText, equals(item.insertText)); expect(textEdit.range, equals(rangeFromMarkers(content))); @@ -396,9 +396,9 @@ await openFile(mainFileUri, withoutMarkers(content)); final res = await getCompletion(mainFileUri, positionFromMarker(content)); final item = res.singleWhere((c) => c.label == 'myFunction(…)'); - // With no required params, there should still be parens and a tabstop inside. + // With no required params, there should still be parens/tab stop inside. expect(item.insertTextFormat, equals(InsertTextFormat.Snippet)); - expect(item.insertText, equals(r'myFunction(${0:})')); + expect(item.insertText, equals(r'myFunction($0)')); final textEdit = toTextEdit(item.textEdit!); expect(textEdit.newText, equals(item.insertText)); expect(textEdit.range, equals(rangeFromMarkers(content))); @@ -1143,9 +1143,9 @@ // Ensure the snippet comes through in the expected format with the expected // placeholder. expect(item.insertTextFormat, equals(InsertTextFormat.Snippet)); - expect(item.insertText, equals(r'one: ${0:},')); + expect(item.insertText, equals(r'one: $0,')); final textEdit = toTextEdit(item.textEdit!); - expect(textEdit.newText, equals(r'one: ${0:},')); + expect(textEdit.newText, equals(r'one: $0,')); expect( textEdit.range, equals(Range(
diff --git a/pkg/analysis_server/test/lsp/mapping_test.dart b/pkg/analysis_server/test/lsp/mapping_test.dart index b55938b..196b547 100644 --- a/pkg/analysis_server/test/lsp/mapping_test.dart +++ b/pkg/analysis_server/test/lsp/mapping_test.dart
@@ -117,38 +117,6 @@ expect(results2, equals(expectedOrder)); } - Future<void> test_tabStopsInSnippets_contains() async { - var result = lsp.buildSnippetStringWithTabStops('a, b, c', [3, 1]); - expect(result, equals(r'a, ${0:b}, c')); - } - - Future<void> test_tabStopsInSnippets_empty() async { - var result = lsp.buildSnippetStringWithTabStops('a, b', []); - expect(result, equals(r'a, b')); - } - - Future<void> test_tabStopsInSnippets_endsWith() async { - var result = lsp.buildSnippetStringWithTabStops('a, b', [3, 1]); - expect(result, equals(r'a, ${0:b}')); - } - - Future<void> test_tabStopsInSnippets_escape() async { - var result = lsp.buildSnippetStringWithTabStops( - r'te$tstri}ng, te$tstri}ng, te$tstri}ng', [13, 11]); - expect(result, equals(r'te\$tstri\}ng, ${0:te\$tstri\}ng}, te\$tstri\}ng')); - } - - Future<void> test_tabStopsInSnippets_multiple() async { - var result = - lsp.buildSnippetStringWithTabStops('a, b, c', [0, 1, 3, 1, 6, 1]); - expect(result, equals(r'${1:a}, ${2:b}, ${3:c}')); - } - - Future<void> test_tabStopsInSnippets_startsWith() async { - var result = lsp.buildSnippetStringWithTabStops('a, b', [0, 1]); - expect(result, equals(r'${0:a}, b')); - } - /// Verifies that [kind] maps to [expectedKind] when the client supports /// [supportedKinds]. void verifyCompletionItemKind({
diff --git a/pkg/analysis_server/test/lsp/snippets_test.dart b/pkg/analysis_server/test/lsp/snippets_test.dart new file mode 100644 index 0000000..ba25212 --- /dev/null +++ b/pkg/analysis_server/test/lsp/snippets_test.dart
@@ -0,0 +1,168 @@ +// Copyright (c) 2022, 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/src/lsp/snippets.dart' as lsp; +import 'package:analysis_server/src/lsp/snippets.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(SnippetsTest); + defineReflectiveTests(SnippetBuilderTest); + }); +} + +@reflectiveTest +class SnippetBuilderTest { + Future<void> test_appendChoice() async { + final builder = SnippetBuilder() + ..appendChoice({r'a'}) + ..appendChoice([r'a', r'b', r'a']) + ..appendChoice([], placeholderNumber: 6) + ..appendChoice([r'aaa', r'bbb'], placeholderNumber: 12) + ..appendChoice([r'aaa', r'bbb $ bbb | bbb } bbb']); + + expect( + builder.value, + r'${1:a}' + r'${2|a,b|}' + r'$6' + r'${12|aaa,bbb|}' + r'${13|aaa,bbb \$ bbb \| bbb \} bbb|}', + ); + } + + Future<void> test_appendPlaceholder() async { + final builder = SnippetBuilder() + ..appendPlaceholder(r'placeholder $ 1') + ..appendPlaceholder(r'') + ..appendPlaceholder(r'placeholder } 3', placeholderNumber: 6); + + expect( + builder.value, + r'${1:placeholder \$ 1}' + r'$2' + r'${6:placeholder \} 3}', + ); + } + + Future<void> test_appendTabStop() async { + final builder = SnippetBuilder() + ..appendTabStop() + ..appendTabStop(placeholderNumber: 10) + ..appendTabStop(); + + expect( + builder.value, + r'$1' + r'$10' + r'$11', + ); + } + + Future<void> test_appendText() async { + final builder = SnippetBuilder() + ..appendText(r'text 1') + ..appendText(r'text ${that needs} escaping $0') + ..appendText(r'text 2'); + + expect( + builder.value, + r'text 1' + r'text \${that needs} escaping \$0' + r'text 2', + ); + } + + Future<void> test_extension_appendPlaceholders() async { + final code = r''' +012345678 +012345678 +012345678 +012345678 +012345678 +'''; + + final placeholders = [ + lsp.SnippetPlaceholder(2, 2), + lsp.SnippetPlaceholder(12, 2, isFinal: true), + lsp.SnippetPlaceholder(22, 2, suggestions: ['aaa', 'bbb']), + lsp.SnippetPlaceholder(32, 2, linkedGroupId: 123), + lsp.SnippetPlaceholder(42, 2, linkedGroupId: 123), + ]; + + final builder = SnippetBuilder()..appendPlaceholders(code, placeholders); + + expect(builder.value, r''' +01${1:23}45678 +01${0:23}45678 +01${2|23,aaa,bbb|}45678 +01${3:23}45678 +01${3:23}45678 +'''); + } + + Future<void> test_mixed() async { + final builder = SnippetBuilder() + ..appendText('text1') + ..appendPlaceholder('placeholder') + ..appendText('text2') + ..appendChoice(['aaa', 'bbb']) + ..appendText('text3') + ..appendTabStop() + ..appendText('text4'); + + expect( + builder.value, + r'text1' + r'${1:placeholder}' + r'text2' + r'${2|aaa,bbb|}' + r'text3' + r'$3' + r'text4', + ); + } +} + +@reflectiveTest +class SnippetsTest { + Future<void> test_tabStops_contains() async { + var result = lsp.buildSnippetStringWithTabStops('a, b, c', [3, 1]); + expect(result, equals(r'a, ${0:b}, c')); + } + + Future<void> test_tabStops_empty() async { + var result = lsp.buildSnippetStringWithTabStops('a, b', []); + expect(result, equals(r'a, b')); + } + + Future<void> test_tabStops_endsWith() async { + var result = lsp.buildSnippetStringWithTabStops('a, b', [3, 1]); + expect(result, equals(r'a, ${0:b}')); + } + + Future<void> test_tabStops_escape() async { + var result = lsp.buildSnippetStringWithTabStops( + r'te$tstri}ng, te$tstri}ng, te$tstri}ng', [13, 11]); + expect(result, equals(r'te\$tstri}ng, ${0:te\$tstri\}ng}, te\$tstri}ng')); + } + + Future<void> test_tabStops_multiple() async { + var result = + lsp.buildSnippetStringWithTabStops('a, b, c', [0, 1, 3, 1, 6, 1]); + expect(result, equals(r'${1:a}, ${2:b}, ${3:c}')); + } + + Future<void> test_tabStops_null() async { + var result = lsp.buildSnippetStringWithTabStops('a, b', null); + expect(result, equals(r'a, b')); + } + + Future<void> test_tabStops_startsWith() async { + var result = lsp.buildSnippetStringWithTabStops('a, b', [0, 1]); + expect(result, equals(r'${0:a}, b')); + } +}
diff --git a/pkg/analysis_server/test/lsp/test_all.dart b/pkg/analysis_server/test/lsp/test_all.dart index bd22081..5e5c5f5 100644 --- a/pkg/analysis_server/test/lsp/test_all.dart +++ b/pkg/analysis_server/test/lsp/test_all.dart
@@ -41,6 +41,7 @@ import 'semantic_tokens_test.dart' as semantic_tokens; import 'server_test.dart' as server; import 'signature_help_test.dart' as signature_help; +import 'snippets_test.dart' as snippets; import 'super_test.dart' as get_super; import 'will_rename_files_test.dart' as will_rename_files; import 'workspace_symbols_test.dart' as workspace_symbols; @@ -85,6 +86,7 @@ semantic_tokens.main(); server.main(); signature_help.main(); + snippets.main(); will_rename_files.main(); workspace_symbols.main(); }, name: 'lsp');
diff --git a/pkg/analysis_server/tool/spec/codegen_protocol_constants.dart b/pkg/analysis_server/tool/spec/codegen_protocol_constants.dart index f6cf4f0..3c16118 100644 --- a/pkg/analysis_server/tool/spec/codegen_protocol_constants.dart +++ b/pkg/analysis_server/tool/spec/codegen_protocol_constants.dart
@@ -71,7 +71,7 @@ writeln(';'); } - /// Generate all of the constants associates with the [api]. + /// Generate all of the constants associated with the [api]. void generateConstants() { writeln("const String PROTOCOL_VERSION = '${api.version}';"); writeln();
diff --git a/pkg/analyzer_plugin/lib/src/utilities/change_builder/change_builder_core.dart b/pkg/analyzer_plugin/lib/src/utilities/change_builder/change_builder_core.dart index 2654817..a1bd021 100644 --- a/pkg/analyzer_plugin/lib/src/utilities/change_builder/change_builder_core.dart +++ b/pkg/analyzer_plugin/lib/src/utilities/change_builder/change_builder_core.dart
@@ -540,7 +540,7 @@ _addEdit(edit); } - /// Add the edit from the given [edit] to the edits associates with the + /// Add the edit from the given [edit] to the edits associated with the /// current file. void _addEdit(SourceEdit edit) { fileEdit.add(edit); @@ -549,7 +549,7 @@ changeBuilder._lockedPositions.clear(); } - /// Add the edit from the given [builder] to the edits associates with the + /// Add the edit from the given [builder] to the edits associated with the /// current file. void _addEditBuilder(EditBuilderImpl builder) { var edit = builder.sourceEdit;
diff --git a/pkg/analyzer_plugin/tool/spec/codegen_protocol_constants.dart b/pkg/analyzer_plugin/tool/spec/codegen_protocol_constants.dart index 648859a..611e583 100644 --- a/pkg/analyzer_plugin/tool/spec/codegen_protocol_constants.dart +++ b/pkg/analyzer_plugin/tool/spec/codegen_protocol_constants.dart
@@ -25,7 +25,7 @@ codeGeneratorSettings.languageName = 'dart'; } - /// Generate all of the constants associates with the [api]. + /// Generate all of the constants associated with the [api]. void generateConstants() { var visitor = _ConstantVisitor(api); visitor.visitApi();