[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();