| // 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:analysis_server/src/protocol_server.dart' | |
| as server | |
| hide AnalysisError; | |
| import 'package:collection/collection.dart'; | |
| /// Builds an LSP snippet string using the supplied edit groups. | |
| /// | |
| /// [editGroups] are provided as absolute positions, where the edit will be | |
| /// made starting at [editOffset]. | |
| /// | |
| /// [selectionOffset] is also absolute and assumes [text] will be | |
| /// inserted at [editOffset]. | |
| String buildSnippetStringForEditGroups( | |
| String text, { | |
| required String filePath, | |
| required List<server.LinkedEditGroup> editGroups, | |
| required int editOffset, | |
| int? selectionOffset, | |
| int? selectionLength, | |
| }) => _buildSnippetString( | |
| text, | |
| filePath: filePath, | |
| editGroups: editGroups, | |
| editGroupsOffset: editOffset, | |
| selectionOffset: selectionOffset != null | |
| ? selectionOffset - editOffset | |
| : null, | |
| selectionLength: selectionLength, | |
| ); | |
| /// 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, | |
| ) => _buildSnippetString( | |
| text, | |
| filePath: null, | |
| tabStopOffsetLengthPairs: tabStopOffsetLengthPairs, | |
| ); | |
| /// Builds an LSP snippet string with supplied ranges as tab stops. | |
| /// | |
| /// [tabStopOffsetLengthPairs] are relative to the supplied text. | |
| /// | |
| /// [selectionOffset]/[selectionLength] form a tab stop that is always "number 0" | |
| /// which is the final tab stop. | |
| /// | |
| /// [editGroups] are provided as absolute positions, where [text] is known to | |
| /// start at [editGroupsOffset] in the final document. | |
| String _buildSnippetString( | |
| String text, { | |
| required String? filePath, | |
| List<int>? tabStopOffsetLengthPairs, | |
| int? selectionOffset, | |
| int? selectionLength, | |
| List<server.LinkedEditGroup>? editGroups, | |
| int editGroupsOffset = 0, | |
| }) { | |
| tabStopOffsetLengthPairs ??= const []; | |
| editGroups ??= const []; | |
| assert(tabStopOffsetLengthPairs.length % 2 == 0); | |
| /// Helper to create a [SnippetPlaceholder] for each position in a linked | |
| /// edit group. | |
| Iterable<SnippetPlaceholder> convertEditGroup( | |
| int index, | |
| server.LinkedEditGroup editGroup, | |
| ) { | |
| var validPositions = editGroup.positions.where((p) => p.file == filePath); | |
| // Create a placeholder for each position in the group. | |
| return validPositions.map( | |
| (position) => SnippetPlaceholder( | |
| // Make the position relative to the supplied text. | |
| position.offset - editGroupsOffset, | |
| editGroup.length, | |
| suggestions: editGroup.suggestions | |
| .map((suggestion) => suggestion.value) | |
| .toList(), | |
| // Use the index as an ID to keep all related positions together (so | |
| // the remain "linked"). | |
| linkedGroupId: index, | |
| // If there is no selection, no tabstops, only a single edit group and | |
| // not multiple suggestions (which map to a choice), allow this to be | |
| // the final tabstop. | |
| isFinal: | |
| selectionOffset == null && | |
| (tabStopOffsetLengthPairs?.isEmpty ?? false) && | |
| editGroups?.length == 1 && | |
| editGroup.suggestions.length <= 1, | |
| ), | |
| ); | |
| } | |
| // Convert selection/tab stops/edit groups all into the same format | |
| // (`_SnippetPlaceholder`) so they can be handled in a single pass through | |
| // the text. | |
| var placeholders = [ | |
| // Selection. | |
| if (selectionOffset != null) | |
| SnippetPlaceholder(selectionOffset, selectionLength ?? 0, isFinal: true), | |
| // 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 (and no selection/editGroups), mark | |
| // it as the final stop so it exit "snippet mode" when tabbed to. | |
| isFinal: | |
| selectionOffset == null && | |
| editGroups.isEmpty && | |
| tabStopOffsetLengthPairs.length == 2, | |
| ), | |
| // Linked edit groups. | |
| ...editGroups.expandIndexed(convertEditGroup), | |
| ]; | |
| // Remove any groups outside of the range of this edit. | |
| // | |
| // There are two reasons we might have edit groups outside of this range. | |
| // | |
| // 1. Some changes produce multiple edits and a placeholder can only belong to | |
| // one, so when building the other, the placeholder will always be out of | |
| // range. For example, the `test()` snippet will insert `test("x")` and | |
| // might also add an `import 'package:test/test.dart';` directive. | |
| // | |
| // 2. Some fixes produce linked edit groups (or selections) that are outside | |
| // of the edit, for example: | |
| // | |
| // ``` | |
| // void f() { | |
| // x(); // invoke "Create function 'x' here" | |
| // } | |
| // ``` | |
| // | |
| // This inserts a new function that tries to link the invocation `x` to the | |
| // name of the new function, so that renaming the new function also updates | |
| // the invocation. However, because the invocation is not part of the edit, it | |
| // cannot be marked up in the snippet text. | |
| // | |
| // See https://github.com/dart-lang/sdk/issues/63824 | |
| placeholders.removeWhere( | |
| (placeholder) => | |
| placeholder.offset < 0 || | |
| placeholder.offset + placeholder.length > text.length, | |
| ); | |
| /// If there are no edit groups, then placeholders are all simple and | |
| /// guaranteed to be in the correct order. | |
| var isPreSorted = editGroups.isEmpty; | |
| var builder = SnippetBuilder() | |
| ..appendPlaceholders(text, placeholders, isPreSorted: isPreSorted); | |
| 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'; | |
| /// Regex used by [escapeSnippetChoiceText]. | |
| static final _escapeSnippetChoiceTextRegex = RegExp( | |
| r'[\\\|,]', | |
| ); // Replace any of \ | , | |
| /// Regex used by [escapeSnippetPlainText]. | |
| static final _escapeSnippetPlainTextRegex = RegExp( | |
| r'[$\\]', | |
| ); // Replace any of $ \ | |
| /// Regex used by [escapeSnippetVariableText]. | |
| static final _escapeSnippetVariableTextRegex = RegExp( | |
| r'[$}\\]', | |
| ); // Replace any of $ } \ | |
| 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. | |
| /// If there are multiple choices, [placeholderNumber] must not be 0. | |
| /// | |
| /// Returns the placeholder number used. | |
| int appendChoice(Set<String> uniqueChoices, {int? placeholderNumber}) { | |
| // If there's only 0/1 items, we can downgrade this to a placeholder. | |
| if (uniqueChoices.length <= 1) { | |
| return appendPlaceholder( | |
| uniqueChoices.firstOrNull ?? '', | |
| placeholderNumber: placeholderNumber, | |
| ); | |
| } | |
| // Otherwise, we will use a choice. In a choice it's not valid to be the | |
| // final (0th) tabstop. | |
| assert(placeholderNumber == null || placeholderNumber > 0); | |
| // To avoid producing broken choice snippets in release builds (if the | |
| // assert above didn't catch issues at dev time), map any final tabstops to | |
| // use the next available tabstop to produce a valid snippet. | |
| if (placeholderNumber == 0) { | |
| placeholderNumber = null; | |
| } | |
| placeholderNumber = _usePlaceholderNumber(placeholderNumber); | |
| var 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 = _usePlaceholderNumber(placeholderNumber); | |
| var escapedText = escapeSnippetVariableText(text); | |
| _buffer.write(r'${'); | |
| _buffer.write(placeholderNumber); | |
| _buffer.write(':'); | |
| _buffer.write(escapedText); | |
| _buffer.write('}'); | |
| return placeholderNumber; | |
| } | |
| /// Appends a tab stop. | |
| /// | |
| /// Returns the placeholder number used. | |
| int appendTabStop({int? placeholderNumber}) { | |
| placeholderNumber = _usePlaceholderNumber(placeholderNumber); | |
| _buffer.write(r'$'); | |
| _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 _usePlaceholderNumber(int? placeholderNumber) { | |
| // If a number was not supplied, use the next 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, _escapeSnippetChoiceTextRegex); | |
| /// 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, _escapeSnippetPlainTextRegex); | |
| /// 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, _escapeSnippetVariableTextRegex); | |
| /// 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; | |
| new( | |
| 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). | |
| /// | |
| /// `isPreSorted` is a performance optimisation that allows skipping some | |
| /// sorting if it's guaranteed that placeholders are already in source-order. | |
| extension SnippetBuilderExtensions on SnippetBuilder { | |
| void appendPlaceholders( | |
| String text, | |
| List<SnippetPlaceholder> placeholders, { | |
| required bool isPreSorted, | |
| }) { | |
| // Ensure placeholders are in the order they're visible in the source so | |
| // tabbing through them doesn't appear to jump around. | |
| if (!isPreSorted) { | |
| 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. | |
| var placeholderIdForLinkedGroupId = <int, int>{}; | |
| var offset = 0; | |
| for (var placeholder in placeholders) { | |
| // Add any text that came before this placeholder to the result. | |
| appendText(text.substring(offset, placeholder.offset)); | |
| var linkedGroupId = placeholder.linkedGroupId; | |
| int? thisPlaceholderNumber; | |
| // 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) { | |
| thisPlaceholderNumber = 0; | |
| } else if (linkedGroupId != null) { | |
| thisPlaceholderNumber = placeholderIdForLinkedGroupId[linkedGroupId]; | |
| } | |
| // Append the placeholder/choices. | |
| var placeholderText = text.substring( | |
| placeholder.offset, | |
| placeholder.offset + placeholder.length, | |
| ); | |
| // appendChoice handles mapping empty/single suggestions to a normal | |
| // placeholder but it's faster if we can avoid putting a single item into | |
| // a set and then detecting it. | |
| if (placeholder.suggestions == null) { | |
| thisPlaceholderNumber = appendPlaceholder( | |
| placeholderText, | |
| placeholderNumber: thisPlaceholderNumber, | |
| ); | |
| } else { | |
| var choices = <String>{ | |
| if (placeholderText.isNotEmpty) placeholderText, | |
| ...?placeholder.suggestions, | |
| }; | |
| thisPlaceholderNumber = appendChoice( | |
| choices, | |
| placeholderNumber: thisPlaceholderNumber, | |
| ); | |
| } | |
| // 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] = thisPlaceholderNumber; | |
| } | |
| } | |
| // Add any remaining text that was after the last placeholder. | |
| appendText(text.substring(offset)); | |
| } | |
| } |