blob: e88dcaa45d56e78e3c16e089897f0d035620c331 [file] [log] [blame]
// 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,
}) =>
_buildSnippetString(
text,
filePath: filePath,
editGroups: editGroups,
editGroupsOffset: editOffset,
selectionOffset:
selectionOffset != null ? selectionOffset - editOffset : null,
);
/// 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,
) {
final 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,
),
);
}
// Convert selection/tab stops/edit groups all into the same format
// (`_SnippetPlaceholder`) so they can be handled in a single pass through
// the text.
final 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 (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));
}
}