| // 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; | |
| } | |
| } | |
| /// Checks if [between] contains a comma that is not part of a comment. | |
| bool betweenHasTrailingComma(String between) { | |
| for (var i = 0; i < between.length; i++) { | |
| final char = between[i]; | |
| if (char == '#') { | |
| // Skip comments up to the next newline. | |
| final nextNewline = between.indexOf('\n', i); | |
| if (nextNewline == -1) break; | |
| i = nextNewline; | |
| continue; | |
| } | |
| if (char == ',') { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /// Formats a new entry [newEntry] for a multiline flow collection with a | |
| /// trailing comma. | |
| /// | |
| /// Ensures the new entry matches the indentation of the previous entry and | |
| /// that the closing delimiter (closing bracket/brace) remains on its own line | |
| /// with its original indentation. | |
| String formatMultilineFlowTrailingEntry({ | |
| required int closingOffset, | |
| required int lastEntryStartOffset, | |
| required String newEntry, | |
| required String yaml, | |
| }) { | |
| final lineEnding = getLineEnding(yaml); | |
| final lastEntryLineStart = yaml.lastIndexOf('\n', lastEntryStartOffset) + 1; | |
| final lastEntryIndent = lastEntryStartOffset - lastEntryLineStart; | |
| final closingLineStart = yaml.lastIndexOf('\n', closingOffset) + 1; | |
| final closingIndent = closingOffset - closingLineStart; | |
| final extraIndent = lastEntryIndent > closingIndent | |
| ? ' ' * (lastEntryIndent - closingIndent) | |
| : ''; | |
| final closingIndentSpaces = ' ' * closingIndent; | |
| return '$extraIndent$newEntry,$lineEnding$closingIndentSpaces'; | |
| } |