blob: e446ad92cd8e173ea56075a05500b7205d3c0b40 [file] [log] [blame]
// Copyright (c) 2020, 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';
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'editor.dart';
import 'source_edit.dart';
import 'wrap.dart';
/// Invoke [fn] while setting [yamlWarningCallback] to [warn], and restore
/// [YamlWarningCallback] after [fn] returns.
///
/// Defaults to a [warn] function that ignores all warnings.
T withYamlWarningCallback<T>(
T Function() fn, {
YamlWarningCallback warn = _ignoreWarning,
}) {
final original = yamlWarningCallback;
try {
yamlWarningCallback = warn;
return fn();
} finally {
yamlWarningCallback = original;
}
}
void _ignoreWarning(String warning, [SourceSpan? span]) {/* ignore warning */}
/// Determines if [string] is dangerous by checking if parsing the plain string
/// can return a result different from [string].
///
/// This function is also capable of detecting if non-printable characters are
/// in [string].
bool isDangerousString(String string) {
try {
final node = withYamlWarningCallback(() => loadYamlNode(string));
if (node.value != string) {
return true;
}
// [string] should also not contain the `[`, `]`, `,`, `{` and `}` indicator
// characters.
return string.contains(RegExp(r'\{|\[|\]|\}|,'));
} catch (e) {
/// This catch statement catches [ArgumentError] in `loadYamlNode` when
/// a string can be interpreted as a URI tag, but catches for other
/// [YamlException]s
return true;
}
}
/// Asserts that [value] is a valid scalar according to YAML.
///
/// A valid scalar is a number, String, boolean, or null.
void assertValidScalar(Object? value) {
if (value is num || value is String || value is bool || value == null) {
return;
}
throw ArgumentError.value(value, 'value', 'Not a valid scalar type!');
}
/// Checks if [node] is a [YamlNode] with block styling.
///
/// [ScalarStyle.ANY] and [CollectionStyle.ANY] are considered to be block
/// styling by default for maximum flexibility.
bool isBlockNode(YamlNode node) {
if (node is YamlScalar) {
if (node.style == ScalarStyle.LITERAL ||
node.style == ScalarStyle.FOLDED ||
node.style == ScalarStyle.ANY) {
return true;
}
}
if (node is YamlList &&
(node.style == CollectionStyle.BLOCK ||
node.style == CollectionStyle.ANY)) {
return true;
}
if (node is YamlMap &&
(node.style == CollectionStyle.BLOCK ||
node.style == CollectionStyle.ANY)) {
return true;
}
return false;
}
/// Returns the content sensitive ending offset of [yamlNode] (i.e. where the
/// last meaningful content happens)
int getContentSensitiveEnd(YamlNode yamlNode) {
if (yamlNode is YamlList) {
if (yamlNode.style == CollectionStyle.FLOW) {
return yamlNode.span.end.offset;
} else {
return getContentSensitiveEnd(yamlNode.nodes.last);
}
} else if (yamlNode is YamlMap) {
if (yamlNode.style == CollectionStyle.FLOW) {
return yamlNode.span.end.offset;
} else {
return getContentSensitiveEnd(yamlNode.nodes.values.last);
}
}
return yamlNode.span.end.offset;
}
/// Checks if the item is a Map or a List
bool isCollection(Object item) => item is Map || item is List;
/// Checks if [index] is [int], >=0, < [length]
bool isValidIndex(Object? index, int length) {
return index is int && index >= 0 && index < length;
}
/// Checks if the item is empty, if it is a List or a Map.
///
/// Returns `false` if [item] is not a List or Map.
bool isEmpty(Object item) {
if (item is Map) return item.isEmpty;
if (item is List) return item.isEmpty;
return false;
}
/// Creates a [SourceSpan] from [sourceUrl] with no meaningful location
/// information.
///
/// Mainly used with [wrapAsYamlNode] to allow for a reasonable
/// implementation of [SourceSpan.message].
SourceSpan shellSpan(Object? sourceUrl) {
final shellSourceLocation = SourceLocation(0, sourceUrl: sourceUrl);
return SourceSpanBase(shellSourceLocation, shellSourceLocation, '');
}
/// Returns if [value] is a [YamlList] or [YamlMap] with [CollectionStyle.FLOW].
bool isFlowYamlCollectionNode(Object value) =>
value is YamlNode && value.collectionStyle == CollectionStyle.FLOW;
/// Determines the index where [newKey] will be inserted if the keys in [map]
/// are in alphabetical order when converted to strings.
///
/// Returns the length of [map] if the keys in [map] are not in alphabetical
/// order.
int getMapInsertionIndex(YamlMap map, Object newKey) {
final keys = map.nodes.keys.map((k) => k.toString()).toList();
// Detect if the keys are not already sorted, append new entry to the end
for (var i = 1; i < keys.length; i++) {
if (keys[i].compareTo(keys[i - 1]) < 0) {
return map.length;
}
}
final insertionIndex =
keys.indexWhere((key) => key.compareTo(newKey as String) > 0);
if (insertionIndex != -1) return insertionIndex;
return map.length;
}
/// Returns the detected indentation step used in [editor], or defaults to a
/// value of `2` if no indentation step can be detected.
///
/// Indentation step is determined by the difference in indentation of the
/// first block-styled yaml collection in the second level as compared to the
/// top-level elements. In the case where there are multiple possible
/// candidates, we choose the candidate closest to the start of [editor].
int getIndentation(YamlEditor editor) {
final node = editor.parseAt([]);
Iterable<YamlNode>? children;
var indentation = 2;
if (node is YamlMap && node.style == CollectionStyle.BLOCK) {
children = node.nodes.values;
} else if (node is YamlList && node.style == CollectionStyle.BLOCK) {
children = node.nodes;
}
if (children != null) {
for (final child in children) {
var indent = 0;
if (child is YamlList) {
indent = getListIndentation(editor.toString(), child);
} else if (child is YamlMap) {
indent = getMapIndentation(editor.toString(), child);
}
if (indent != 0) indentation = indent;
}
}
return indentation;
}
/// Gets the indentation level of [list]. This is 0 if it is a flow list,
/// but returns the number of spaces before the hyphen of elements for
/// block lists.
///
/// Throws [UnsupportedError] if an empty block map is passed in.
int getListIndentation(String yaml, YamlList list) {
if (list.style == CollectionStyle.FLOW) return 0;
/// An empty block map doesn't really exist.
if (list.isEmpty) {
throw UnsupportedError('Unable to get indentation for empty block list');
}
final lastSpanOffset = list.nodes.last.span.start.offset;
final lastHyphen = yaml.lastIndexOf('-', lastSpanOffset - 1);
if (lastHyphen == 0) return lastHyphen;
// Look for '\n' that's before hyphen
final lastNewLine = yaml.lastIndexOf('\n', lastHyphen - 1);
return lastHyphen - lastNewLine - 1;
}
/// Gets the indentation level of [map]. This is 0 if it is a flow map,
/// but returns the number of spaces before the keys for block maps.
int getMapIndentation(String yaml, YamlMap map) {
if (map.style == CollectionStyle.FLOW) return 0;
/// An empty block map doesn't really exist.
if (map.isEmpty) {
throw UnsupportedError('Unable to get indentation for empty block map');
}
/// Use the number of spaces between the last key and the newline as
/// indentation.
final lastKey = map.nodes.keys.last as YamlNode;
final lastSpanOffset = lastKey.span.start.offset;
final lastNewLine = yaml.lastIndexOf('\n', lastSpanOffset);
final lastQuestionMark = yaml.lastIndexOf('?', lastSpanOffset);
if (lastQuestionMark == -1) {
if (lastNewLine == -1) return lastSpanOffset;
return lastSpanOffset - lastNewLine - 1;
}
/// If there is a question mark, it might be a complex key. Check if it
/// is on the same line as the key node to verify.
if (lastNewLine == -1) return lastQuestionMark;
if (lastQuestionMark > lastNewLine) {
return lastQuestionMark - lastNewLine - 1;
}
return lastSpanOffset - lastNewLine - 1;
}
/// Returns the detected line ending used in [yaml], more specifically, whether
/// [yaml] appears to use Windows `\r\n` or Unix `\n` line endings.
///
/// The heuristic used is to count all `\n` in the text and if strictly more
/// than half of them are preceded by `\r` we report that windows line endings
/// are used.
String getLineEnding(String yaml) {
var index = -1;
var unixNewlines = 0;
var windowsNewlines = 0;
while ((index = yaml.indexOf('\n', index + 1)) != -1) {
if (index != 0 && yaml[index - 1] == '\r') {
windowsNewlines++;
} else {
unixNewlines++;
}
}
return windowsNewlines > unixNewlines ? '\r\n' : '\n';
}
final _nonSpaceMatch = RegExp(r'[^ \t]');
/// Skip empty lines and returns the offset of the last possible line ending
/// only if the [offset] is a valid offset within the [yaml] string that
/// points to first line ending.
///
/// The [blockIndent] is used to truncate any comments more indented than the
/// parent collection that may affect other block entries within the collection
/// that may have block scalars.
int indexOfLastLineEnding(
String yaml, {
required int offset,
required int blockIndent,
}) {
if (yaml.isEmpty || offset == -1) return yaml.length;
final lastOffset = yaml.length - 1;
var currentOffset = min(offset, lastOffset);
// Unsafe. Cannot start our scanner state machine in an unguarded state.
if (yaml[currentOffset] != '\r' && yaml[currentOffset] != '\n') {
return currentOffset;
}
var lineEndingIndex = currentOffset;
// Skip empty lines and any comments indented more than the block entry. Such
// comments are hazardous to block scalars.
scanner:
while (currentOffset <= lastOffset) {
switch (yaml[currentOffset]) {
case '\r':
{
// Skip carriage return if possible. No use to us if we have a line
// feed after.
if (currentOffset < lastOffset && yaml[currentOffset + 1] == '\n') {
++currentOffset;
}
continue indentChecker;
}
indentChecker:
case '\n':
{
lineEndingIndex = currentOffset;
++currentOffset;
if (currentOffset >= lastOffset) {
lineEndingIndex = lastOffset;
break scanner;
}
final offsetAfterIndent = yaml.indexOf(RegExp('[^ ]'), currentOffset);
// No more characters!
if (offsetAfterIndent == -1) {
lineEndingIndex = lastOffset;
break scanner;
}
final indent = offsetAfterIndent - currentOffset;
currentOffset = offsetAfterIndent;
final charAfterIndent = yaml[currentOffset];
if (charAfterIndent case '\r' || '\n') {
continue scanner;
} else if (indent > blockIndent) {
// If more indented than the entry, always attempt to truncate the
// comment or skip it as an empty line.
if (charAfterIndent == '\t') {
continue skipIfEmpty;
} else if (charAfterIndent == '#') {
continue truncateComment;
}
}
break scanner;
}
// Guarded by indentChecker. Force tabs to be associated with empty lines
// if seen past the indent.
skipIfEmpty:
case '\t':
{
final nonSpace = yaml.indexOf(_nonSpaceMatch, currentOffset);
if (nonSpace == -1) {
lineEndingIndex = lastOffset;
} else if (yaml[nonSpace] case '\r' || '\n') {
currentOffset = nonSpace;
continue scanner;
}
break scanner;
}
// Guarded by indentChecker. This ensures we only skip comments indented
// more than the entry itself.
truncateComment:
case '#':
{
final lineFeedOffset = yaml.indexOf('\n', currentOffset);
if (lineFeedOffset == -1) {
lineEndingIndex = lastOffset;
break scanner;
}
currentOffset = lineFeedOffset;
continue indentChecker;
}
default:
break scanner;
}
}
return lineEndingIndex;
}
/// Backtracks from the [start] offset and looks for the nearest character
/// that is not a separation space (tab/space) that can be used to declare a
/// nested compact block map
///
/// ```yaml
/// # In a block list
/// - key: value
/// next: value
///
/// ---
/// # In an explicit key and its value
///
/// ? key: value
/// next: value
/// : key: value
/// next: value
/// ```
///
/// If a line feed `\n` is encountered first then `compactCharOffset` defaults
/// to -1. Otherwise, only returns a non-negative `compactCharOffset` if
/// `?`, `-` or `:` were seen.
({int compactCharOffset, int lineEndingIndex}) indexOfCompactChar(
String yaml,
int start,
) {
/// Look back past the indent/separation space.
final startOffset = max(
0,
yaml.lastIndexOf(_nonSpaceMatch, max(0, start - 1)),
);
return switch (yaml[startOffset]) {
'\r' || '\n' => (compactCharOffset: -1, lineEndingIndex: startOffset),
/// Block sequences and explicit keys/values can be used to declare block
/// maps/sequences in a compact-inline notation.
///
/// - a: b
/// c: d
///
/// - - a
/// - b
///
/// OR as an explicit key with its explicit value
///
/// ? a: b
/// c: d
/// : e: f
/// g: h
///
/// ? - sequence
/// - as key
/// : - sequence
/// - as value
///
/// See "Example 8.19 Compact Block Mappings" at
/// https://yaml.org/spec/1.2.2/#822-block-mappings
'-' || '?' || ':' => (compactCharOffset: startOffset, lineEndingIndex: -1),
_ => (compactCharOffset: -1, lineEndingIndex: -1)
};
}
typedef NextBlockNodeInfo = ({int nearestLineEnding, int nextNodeColStart});
typedef BlockNodeOffset = ({int start, int end});
/// Removes a block entry and its line ending from a [blockCollection] using the
/// [nodeToRemoveOffset] provided. Any trailing comments are also removed.
///
/// If [blockCollection] is a [YamlMap], the chunk removed corresponds to the
/// key-value pair represented by the offset. If [blockCollection] is a
/// [YamlList], the chunk represents a single element within the list.
///
/// [nextBlockNodeInfo] is only called if the [blockCollection] has at least
/// 2 entries and the entry being removed is not the last entry in the
/// collection.
SourceEdit removeBlockCollectionEntry(
String yaml, {
required YamlNode blockCollection,
required int collectionIndent,
required bool isFirstEntry,
required bool isSingleEntry,
required bool isLastEntry,
required BlockNodeOffset nodeToRemoveOffset,
required String lineEnding,
required NextBlockNodeInfo Function() nextBlockNodeInfo,
}) {
final isBlockList = blockCollection is YamlList;
assert(
isBlockList || blockCollection is YamlMap,
'Expected a block map/list',
);
var makeNextNodeCompact = false;
var (:start, :end) = nodeToRemoveOffset;
// Skip empty lines to the last line break
end = indexOfLastLineEnding(
yaml,
offset: yaml.indexOf('\n', end - 1),
blockIndent: collectionIndent,
);
end = min(++end, yaml.length); // Mark it for removal
if (isSingleEntry) {
// Preserve when sandwiched or if an EOF line ending was present.
if (end < yaml.length || yaml.endsWith('\n')) {
end -= lineEnding == '\r\n' ? 2 : 1;
}
return SourceEdit(start, end - start, isBlockList ? '[]' : '{}');
} else if (isLastEntry) {
start = yaml.lastIndexOf('\n', start) + 1;
end = max(yaml.lastIndexOf('\n', blockCollection.span.end.offset) + 1, end);
return SourceEdit(start, end - start, '');
}
if (start != 0) {
// Try making it compact in case the collection's parent is a:
// - block sequence
// - explicit key
// - explicit key's explicit value.
if (isFirstEntry) {
final (:compactCharOffset, :lineEndingIndex) = indexOfCompactChar(
yaml,
start,
);
if (compactCharOffset != -1) {
start = compactCharOffset + 2; // Skip separation space.
makeNextNodeCompact = true;
} else {
start = lineEndingIndex + 1;
}
} else {
// If not possible, just consume this node's indent. This prevents this
// node from interfering with the next node.
start = yaml.lastIndexOf('\n', start) + 1;
}
}
final (:nearestLineEnding, :nextNodeColStart) = nextBlockNodeInfo();
final trueEndOffset = end - 1;
// Make compact only if we are pointing to same line break from different
// node extremes. This can only be true if we are removing the first
// entry.
//
// ** For block lists **
//
// [*] Before:
//
// - - value
// - next
//
// [*] After:
//
// - - next
//
// ** For block maps **
//
// [*] Before:
//
// - key: value
// next: value
//
// [*] After:
//
// - next: value
end = makeNextNodeCompact && nearestLineEnding == trueEndOffset
? end + nextNodeColStart
: end;
return SourceEdit(start, end - start, '');
}
extension YamlNodeExtension on YamlNode {
/// Returns the [CollectionStyle] of `this` if `this` is [YamlMap] or
/// [YamlList].
///
/// Otherwise, returns `null`.
CollectionStyle? get collectionStyle {
final me = this;
if (me is YamlMap) return me.style;
if (me is YamlList) return me.style;
return null;
}
}