| // 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)); |
| } |
| } |