Vendor yaml_edit (#2633)
diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index 4839545..aabd67f 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -3,7 +3,6 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:pub_semver/pub_semver.dart';
-import 'package:yaml_edit/yaml_edit.dart';
import '../command.dart';
import '../entrypoint.dart';
@@ -16,6 +15,7 @@
import '../pubspec.dart';
import '../solver.dart';
import '../utils.dart';
+import '../yaml_edit/editor.dart';
/// Handles the `add` pub command. Adds a dependency to `pubspec.yaml` and gets
/// the package. The user may pass in a git constraint, host url, or path as
diff --git a/lib/src/command/remove.dart b/lib/src/command/remove.dart
index dc3bd26..94091c7 100644
--- a/lib/src/command/remove.dart
+++ b/lib/src/command/remove.dart
@@ -2,8 +2,6 @@
// 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 'package:yaml_edit/yaml_edit.dart';
-
import '../command.dart';
import '../entrypoint.dart';
import '../io.dart';
@@ -11,6 +9,7 @@
import '../package.dart';
import '../pubspec.dart';
import '../solver.dart';
+import '../yaml_edit/editor.dart';
/// Handles the `remove` pub command. Removes dependencies from `pubspec.yaml`,
/// and performs an operation similar to `pub get`. Unlike `pub add`, this
diff --git a/lib/src/yaml_edit/editor.dart b/lib/src/yaml_edit/editor.dart
new file mode 100644
index 0000000..fa397ce
--- /dev/null
+++ b/lib/src/yaml_edit/editor.dart
@@ -0,0 +1,632 @@
+// 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 'package:meta/meta.dart';
+import 'package:yaml/yaml.dart';
+
+import 'equality.dart';
+import 'errors.dart';
+import 'list_mutations.dart';
+import 'map_mutations.dart';
+import 'source_edit.dart';
+import 'strings.dart';
+import 'utils.dart';
+import 'wrap.dart';
+
+/// An interface for modififying [YAML][1] documents while preserving comments
+/// and whitespaces.
+///
+/// YAML parsing is supported by `package:yaml`, and modifications are performed
+/// as string operations. An error will be thrown if internal assertions fail -
+/// such a situation should be extremely rare, and should only occur with
+/// degenerate formatting.
+///
+/// Most modification methods require the user to pass in an [Iterable<Object>]
+/// path that holds the keys/indices to navigate to the element.
+///
+/// **Example:**
+/// ```yaml
+/// a: 1
+/// b: 2
+/// c:
+/// - 3
+/// - 4
+/// - {e: 5, f: [6, 7]}
+/// ```
+///
+/// To get to `7`, our path will be `['c', 2, 'f', 1]`. The path for the base
+/// object is the empty array `[]`. All modification methods will throw a
+/// [Argu,emtError] if the path provided is invalid. Note also that that the order
+/// of elements in the path is important, and it should be arranged in order of
+/// calling, with the first element being the first key or index to be called.
+///
+/// In most modification methods, users are required to pass in a value to be
+/// used for updating the YAML tree. This value is only allowed to either be a
+/// valid scalar that is recognizable by YAML (i.e. `bool`, `String`, `List`,
+/// `Map`, `num`, `null`) or a [YamlNode]. Should the user want to specify
+/// the style to be applied to the value passed in, the user may wrap the value
+/// using [wrapAsYamlNode] while passing in the appropriate `scalarStyle` or
+/// `collectionStyle`. While we try to respect the style that is passed in,
+/// there will be instances where the formatting will not result in valid YAML,
+/// and as such we will fallback to a default formatting while preserving the
+/// content.
+///
+/// To dump the YAML after all the modifications have been completed, simply
+/// call [toString()].
+///
+/// [1]: https://yaml.org/
+@sealed
+class YamlEditor {
+ final List<SourceEdit> _edits = [];
+
+ /// List of [SourceEdit]s that have been applied to [_yaml] since the creation
+ /// of this instance, in chronological order. Intended to be compatible with
+ /// `package:analysis_server`.
+ ///
+ /// The [SourceEdit] objects can be serialized to JSON using the `toJSON`
+ /// function, deserialized using [SourceEdit.fromJSON], and applied to a
+ /// string using the `apply` function. Multiple [SourceEdit]s can be applied
+ /// to a string using [SourceEdit.applyAll].
+ ///
+ /// For more information, refer to the [SourceEdit] class.
+ List<SourceEdit> get edits => [..._edits];
+
+ /// Current YAML string.
+ String _yaml;
+
+ /// Root node of YAML AST.
+ YamlNode _contents;
+
+ /// Stores the list of nodes in [_contents] that are connected by aliases.
+ ///
+ /// When a node is anchored with an alias and subsequently referenced,
+ /// the full content of the anchored node is thought to be copied in the
+ /// following references.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// a: &SS Sammy Sosa
+ /// b: *SS
+ /// ```
+ ///
+ /// is equivalent to
+ ///
+ /// ```dart
+ /// a: Sammy Sosa
+ /// b: Sammy Sosa
+ /// ```
+ ///
+ /// As such, aliased nodes have to be treated with special caution when
+ /// any modification is taking place.
+ ///
+ /// See 7.1 Alias Nodes: https://yaml.org/spec/1.2/spec.html#id2786196
+ Set<YamlNode> _aliases = {};
+
+ /// Returns the current YAML string.
+ @override
+ String toString() => _yaml;
+
+ factory YamlEditor(String yaml) => YamlEditor._(yaml);
+
+ YamlEditor._(this._yaml) {
+ ArgumentError.checkNotNull(_yaml);
+ _initialize();
+ }
+
+ /// Loads [_contents] from [_yaml], and traverses the YAML tree formed to
+ /// detect alias nodes.
+ void _initialize() {
+ _contents = loadYamlNode(_yaml);
+ _aliases = {};
+
+ /// Performs a DFS on [_contents] to detect alias nodes.
+ final visited = <YamlNode>{};
+ void collectAliases(YamlNode node) {
+ if (visited.add(node)) {
+ if (node is YamlMap) {
+ node.nodes.forEach((key, value) {
+ collectAliases(key);
+ collectAliases(value);
+ });
+ } else if (node is YamlList) {
+ node.nodes.forEach(collectAliases);
+ }
+ } else {
+ _aliases.add(node);
+ }
+ }
+
+ collectAliases(_contents);
+ }
+
+ /// Parses the document to return [YamlNode] currently present at [path].
+ ///
+ /// If no [YamlNode]s exist at [path], the result of invoking the [orElse]
+ /// function is returned.
+ ///
+ /// If [orElse] is omitted, it defaults to throwing a [ArgumentError].
+ ///
+ /// To get `null` when [path] does not point to a value in the [YamlNode]-tree,
+ /// simply pass `orElse: () => null`.
+ ///
+ /// **Example:** (using orElse)
+ /// ```dart
+ /// final myYamlEditor('{"key": "value"}');
+ /// final value = myYamlEditor.valueAt(['invalid', 'path'], orElse: () => null);
+ /// print(value) // null
+ /// ```
+ ///
+ /// **Example:** (common usage)
+ /// ```dart
+ /// final doc = YamlEditor('''
+ /// a: 1
+ /// b:
+ /// d: 4
+ /// e: [5, 6, 7]
+ /// c: 3
+ /// ''');
+ /// print(doc.parseAt(['b', 'e', 2])); // 7
+ /// ```
+ /// The value returned by [parseAt] is invalidated when the documented is
+ /// mutated, as illustrated below:
+ ///
+ /// **Example:** (old [parseAt] value is invalidated)
+ /// ```dart
+ /// final doc = YamlEditor("YAML: YAML Ain't Markup Language");
+ /// final node = doc.parseAt(['YAML']);
+ ///
+ /// print(node.value); // Expected output: "YAML Ain't Markup Language"
+ ///
+ /// doc.update(['YAML'], 'YAML');
+ ///
+ /// final newNode = doc.parseAt(['YAML']);
+ ///
+ /// // Note that the value does not change
+ /// print(newNode.value); // "YAML"
+ /// print(node.value); // "YAML Ain't Markup Language"
+ /// ```
+ YamlNode parseAt(Iterable<Object> path, {YamlNode Function() orElse}) {
+ ArgumentError.checkNotNull(path, 'path');
+
+ return _traverse(path, orElse: orElse);
+ }
+
+ /// Sets [value] in the [path].
+ ///
+ /// There is a subtle difference between [update] and [remove] followed by
+ /// an [insertIntoList], because [update] preserves comments at the same level.
+ ///
+ /// Throws a [ArgumentError] if [path] is invalid.
+ ///
+ /// **Example:** (using [update])
+ /// ```dart
+ /// final doc = YamlEditor('''
+ /// - 0
+ /// - 1 # comment
+ /// - 2
+ /// ''');
+ /// doc.update([1], 'test');
+ /// ```
+ ///
+ /// **Expected Output:**
+ /// ```yaml
+ /// - 0
+ /// - test # comment
+ /// - 2
+ /// ```
+ ///
+ /// **Example:** (using [remove] and [insertIntoList])
+ /// ```dart
+ /// final doc2 = YamlEditor('''
+ /// - 0
+ /// - 1 # comment
+ /// - 2
+ /// ''');
+ /// doc2.remove([1]);
+ /// doc2.insertIntoList([], 1, 'test');
+ /// ```
+ ///
+ /// **Expected Output:**
+ /// ```yaml
+ /// - 0
+ /// - test
+ /// - 2
+ /// ```
+ void update(Iterable<Object> path, Object value) {
+ ArgumentError.checkNotNull(path, 'path');
+
+ final valueNode = wrapAsYamlNode(value);
+
+ if (path.isEmpty) {
+ final start = _contents.span.start.offset;
+ final end = getContentSensitiveEnd(_contents);
+ final lineEnding = getLineEnding(_yaml);
+ final edit = SourceEdit(
+ start, end - start, yamlEncodeBlockString(valueNode, 0, lineEnding));
+
+ return _performEdit(edit, path, valueNode);
+ }
+
+ final pathAsList = path.toList();
+ final collectionPath = pathAsList.take(path.length - 1);
+ final keyOrIndex = pathAsList.last;
+ final parentNode = _traverse(collectionPath, checkAlias: true);
+
+ if (parentNode is YamlList) {
+ final expected = wrapAsYamlNode(
+ [...parentNode.nodes]..[keyOrIndex] = valueNode,
+ );
+
+ return _performEdit(updateInList(this, parentNode, keyOrIndex, valueNode),
+ collectionPath, expected);
+ }
+
+ if (parentNode is YamlMap) {
+ final expectedMap =
+ updatedYamlMap(parentNode, (nodes) => nodes[keyOrIndex] = valueNode);
+ return _performEdit(updateInMap(this, parentNode, keyOrIndex, valueNode),
+ collectionPath, expectedMap);
+ }
+
+ throw PathError.unexpected(
+ path, 'Scalar $parentNode does not have key $keyOrIndex');
+ }
+
+ /// Appends [value] to the list at [path].
+ ///
+ /// Throws a [ArgumentError] if the element at the given path is not a [YamlList]
+ /// or if the path is invalid.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final doc = YamlEditor('[0, 1]');
+ /// doc.appendToList([], 2); // [0, 1, 2]
+ /// ```
+ void appendToList(Iterable<Object> path, Object value) {
+ ArgumentError.checkNotNull(path, 'path');
+ final yamlList = _traverseToList(path);
+
+ insertIntoList(path, yamlList.length, value);
+ }
+
+ /// Prepends [value] to the list at [path].
+ ///
+ /// Throws a [ArgumentError] if the element at the given path is not a [YamlList]
+ /// or if the path is invalid.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final doc = YamlEditor('[1, 2]');
+ /// doc.prependToList([], 0); // [0, 1, 2]
+ /// ```
+ void prependToList(Iterable<Object> path, Object value) {
+ ArgumentError.checkNotNull(path, 'path');
+
+ insertIntoList(path, 0, value);
+ }
+
+ /// Inserts [value] into the list at [path].
+ ///
+ /// [index] must be non-negative and no greater than the list's length.
+ ///
+ /// Throws a [ArgumentError] if the element at the given path is not a [YamlList]
+ /// or if the path is invalid.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final doc = YamlEditor('[0, 2]');
+ /// doc.insertIntoList([], 1, 1); // [0, 1, 2]
+ /// ```
+ void insertIntoList(Iterable<Object> path, int index, Object value) {
+ ArgumentError.checkNotNull(path, 'path');
+ final valueNode = wrapAsYamlNode(value);
+
+ final list = _traverseToList(path, checkAlias: true);
+ RangeError.checkValueInInterval(index, 0, list.length);
+
+ final edit = insertInList(this, list, index, valueNode);
+ final expected = wrapAsYamlNode(
+ [...list.nodes]..insert(index, valueNode),
+ );
+
+ _performEdit(edit, path, expected);
+ }
+
+ /// Changes the contents of the list at [path] by removing [deleteCount] items
+ /// at [index], and inserting [values] in-place. Returns the elements that
+ /// are deleted.
+ ///
+ /// [index] and [deleteCount] must be non-negative and [index] + [deleteCount]
+ /// must be no greater than the list's length.
+ ///
+ /// Throws a [ArgumentError] if the element at the given path is not a [YamlList]
+ /// or if the path is invalid.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final doc = YamlEditor('[Jan, March, April, June]');
+ /// doc.spliceList([], 1, 0, ['Feb']); // [Jan, Feb, March, April, June]
+ /// doc.spliceList([], 4, 1, ['May']); // [Jan, Feb, March, April, May]
+ /// ```
+ Iterable<YamlNode> spliceList(Iterable<Object> path, int index,
+ int deleteCount, Iterable<Object> values) {
+ ArgumentError.checkNotNull(path, 'path');
+ ArgumentError.checkNotNull(index, 'index');
+ ArgumentError.checkNotNull(deleteCount, 'deleteCount');
+ ArgumentError.checkNotNull(values, 'values');
+
+ final list = _traverseToList(path, checkAlias: true);
+
+ RangeError.checkValueInInterval(index, 0, list.length);
+ RangeError.checkValueInInterval(index + deleteCount, 0, list.length);
+
+ final nodesToRemove = list.nodes.getRange(index, index + deleteCount);
+
+ /// Perform addition of elements before removal to avoid scenarioes where
+ /// a block list gets emptied out to {} to avoid changing collection styles
+ /// where possible.
+
+ /// Reverse [values] and insert them.
+ final reversedValues = values.toList().reversed;
+ for (final value in reversedValues) {
+ insertIntoList(path, index, value);
+ }
+
+ for (var i = 0; i < deleteCount; i++) {
+ remove([...path, index + values.length]);
+ }
+
+ return nodesToRemove;
+ }
+
+ /// Removes the node at [path]. Comments "belonging" to the node will be
+ /// removed while surrounding comments will be left untouched.
+ ///
+ /// Throws a [ArgumentError] if [path] is invalid.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final doc = YamlEditor('''
+ /// - 0 # comment 0
+ /// # comment A
+ /// - 1 # comment 1
+ /// # comment B
+ /// - 2 # comment 2
+ /// ''');
+ /// doc.remove([1]);
+ /// ```
+ ///
+ /// **Expected Result:**
+ /// ```dart
+ /// '''
+ /// - 0 # comment 0
+ /// # comment A
+ /// # comment B
+ /// - 2 # comment 2
+ /// '''
+ /// ```
+ YamlNode remove(Iterable<Object> path) {
+ ArgumentError.checkNotNull(path, 'path');
+
+ SourceEdit edit;
+ YamlNode expectedNode;
+ final nodeToRemove = _traverse(path, checkAlias: true);
+
+ if (path.isEmpty) {
+ edit = SourceEdit(0, _yaml.length, '');
+
+ /// Parsing an empty YAML document returns `null`.
+ _performEdit(edit, path, expectedNode);
+ return nodeToRemove;
+ }
+
+ final pathAsList = path.toList();
+ final collectionPath = pathAsList.take(path.length - 1);
+ final keyOrIndex = pathAsList.last;
+ final parentNode = _traverse(collectionPath);
+
+ if (parentNode is YamlList) {
+ edit = removeInList(this, parentNode, keyOrIndex);
+ expectedNode = wrapAsYamlNode(
+ [...parentNode.nodes]..removeAt(keyOrIndex),
+ );
+ } else if (parentNode is YamlMap) {
+ edit = removeInMap(this, parentNode, keyOrIndex);
+
+ expectedNode =
+ updatedYamlMap(parentNode, (nodes) => nodes.remove(keyOrIndex));
+ }
+
+ _performEdit(edit, collectionPath, expectedNode);
+
+ return nodeToRemove;
+ }
+
+ /// Traverses down [path] to return the [YamlNode] at [path] if successful.
+ ///
+ /// If no [YamlNode]s exist at [path], the result of invoking the [orElse]
+ /// function is returned.
+ ///
+ /// If [orElse] is omitted, it defaults to throwing a [PathError].
+ ///
+ /// If [checkAlias] is `true`, throw [AliasError] if an aliased node is
+ /// encountered.
+ YamlNode _traverse(Iterable<Object> path,
+ {bool checkAlias = false, YamlNode Function() orElse}) {
+ ArgumentError.checkNotNull(path, 'path');
+ ArgumentError.checkNotNull(checkAlias, 'checkAlias');
+
+ if (path.isEmpty) return _contents;
+
+ var currentNode = _contents;
+ final pathList = path.toList();
+
+ for (var i = 0; i < pathList.length; i++) {
+ final keyOrIndex = pathList[i];
+
+ if (checkAlias && _aliases.contains(currentNode)) {
+ throw AliasError(path, currentNode);
+ }
+
+ if (currentNode is YamlList) {
+ final list = currentNode as YamlList;
+ if (!isValidIndex(keyOrIndex, list.length)) {
+ return _pathErrorOrElse(path, path.take(i + 1), list, orElse);
+ }
+
+ currentNode = list.nodes[keyOrIndex];
+ } else if (currentNode is YamlMap) {
+ final map = currentNode as YamlMap;
+
+ if (!containsKey(map, keyOrIndex)) {
+ return _pathErrorOrElse(path, path.take(i + 1), map, orElse);
+ }
+ final keyNode = getKeyNode(map, keyOrIndex);
+
+ if (checkAlias) {
+ if (_aliases.contains(keyNode)) throw AliasError(path, keyNode);
+ }
+
+ currentNode = map.nodes[keyNode];
+ } else {
+ return _pathErrorOrElse(path, path.take(i + 1), currentNode, orElse);
+ }
+ }
+
+ if (checkAlias) _assertNoChildAlias(path, currentNode);
+
+ return currentNode;
+ }
+
+ /// Throws a [PathError] if [orElse] is not provided, returns the result
+ /// of invoking the [orElse] function otherwise.
+ YamlNode _pathErrorOrElse(Iterable<Object> path, Iterable<Object> subPath,
+ YamlNode parent, YamlNode Function() orElse) {
+ if (orElse == null) throw PathError(path, subPath, parent);
+ return orElse();
+ }
+
+ /// Asserts that [node] and none its children are aliases
+ void _assertNoChildAlias(Iterable<Object> path, [YamlNode node]) {
+ ArgumentError.checkNotNull(path, 'path');
+
+ if (node == null) return _assertNoChildAlias(path, _traverse(path));
+ if (_aliases.contains(node)) throw AliasError(path, node);
+
+ if (node is YamlScalar) return;
+
+ if (node is YamlList) {
+ for (var i = 0; i < node.length; i++) {
+ final updatedPath = [...path, i];
+ _assertNoChildAlias(updatedPath, node.nodes[i]);
+ }
+ }
+
+ if (node is YamlMap) {
+ final keyList = node.keys.toList();
+ for (var i = 0; i < node.length; i++) {
+ final updatedPath = [...path, keyList[i]];
+ if (_aliases.contains(keyList[i])) throw AliasError(path, keyList[i]);
+ _assertNoChildAlias(updatedPath, node.nodes[keyList[i]]);
+ }
+ }
+ }
+
+ /// Traverses down the provided [path] to return the [YamlList] at [path].
+ ///
+ /// Convenience function to ensure that a [YamlList] is returned.
+ ///
+ /// Throws [ArgumentError] if the element at the given path is not a [YamlList]
+ /// or if the path is invalid. If [checkAlias] is `true`, and an aliased node
+ /// is encountered along [path], an [AliasError] will be thrown.
+ YamlList _traverseToList(Iterable<Object> path, {bool checkAlias = false}) {
+ ArgumentError.checkNotNull(path, 'path');
+ ArgumentError.checkNotNull(checkAlias, 'checkAlias');
+
+ final possibleList = _traverse(path, checkAlias: true);
+
+ if (possibleList is YamlList) {
+ return possibleList;
+ } else {
+ throw PathError.unexpected(
+ path, 'Path $path does not point to a YamlList!');
+ }
+ }
+
+ /// Utility method to replace the substring of [_yaml] according to [edit].
+ ///
+ /// When [_yaml] is modified with this method, the resulting string is parsed
+ /// and reloaded and traversed down [path] to ensure that the reloaded YAML
+ /// tree is equal to our expectations by deep equality of values. Throws an
+ /// [AssertionError] if the two trees do not match.
+ void _performEdit(
+ SourceEdit edit, Iterable<Object> path, YamlNode expectedNode) {
+ ArgumentError.checkNotNull(edit, 'edit');
+ ArgumentError.checkNotNull(path, 'path');
+
+ final expectedTree = _deepModify(_contents, path, [], expectedNode);
+ _yaml = edit.apply(_yaml);
+ _initialize();
+
+ final actualTree = loadYamlNode(_yaml);
+ if (!deepEquals(actualTree, expectedTree)) {
+ throw AssertionError('''
+Modification did not result in expected result!
+
+Obtained:
+$actualTree
+
+Expected:
+$expectedTree''');
+ }
+
+ _contents = actualTree;
+ _edits.add(edit);
+ }
+
+ /// Utility method to produce an updated YAML tree equivalent to converting
+ /// the [YamlNode] at [path] to be [expectedNode]. [subPath] holds the portion
+ /// of [path] that has been traversed thus far.
+ ///
+ /// Throws a [PathError] if path is invalid.
+ ///
+ /// When called, it creates a new [YamlNode] of the same type as [tree], and
+ /// copies its children over, except for the child that is on the path. Doing
+ /// so allows us to "update" the immutable [YamlNode] without having to clone
+ /// the whole tree.
+ ///
+ /// [SourceSpan]s in this new tree are not guaranteed to be accurate.
+ YamlNode _deepModify(YamlNode tree, Iterable<Object> path,
+ Iterable<Object> subPath, YamlNode expectedNode) {
+ ArgumentError.checkNotNull(path, 'path');
+ ArgumentError.checkNotNull(tree, 'tree');
+ RangeError.checkValueInInterval(subPath.length, 0, path.length);
+
+ if (path.length == subPath.length) return expectedNode;
+
+ final keyOrIndex = path.elementAt(subPath.length);
+
+ if (tree is YamlList) {
+ if (!isValidIndex(keyOrIndex, tree.length)) {
+ throw PathError(path, subPath, tree);
+ }
+
+ return wrapAsYamlNode([...tree.nodes]..[keyOrIndex] = _deepModify(
+ tree.nodes[keyOrIndex],
+ path,
+ path.take(subPath.length + 1),
+ expectedNode));
+ }
+
+ if (tree is YamlMap) {
+ return updatedYamlMap(
+ tree,
+ (nodes) => nodes[keyOrIndex] = _deepModify(nodes[keyOrIndex], path,
+ path.take(subPath.length + 1), expectedNode));
+ }
+
+ /// Should not ever reach here.
+ throw PathError(path, subPath, tree);
+ }
+}
diff --git a/lib/src/yaml_edit/equality.dart b/lib/src/yaml_edit/equality.dart
new file mode 100644
index 0000000..21ad6a0
--- /dev/null
+++ b/lib/src/yaml_edit/equality.dart
@@ -0,0 +1,116 @@
+// 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:collection';
+
+import 'package:collection/collection.dart';
+import 'package:yaml/yaml.dart';
+
+/// Creates a map that uses our custom [deepEquals] and [deepHashCode] functions
+/// to determine equality.
+Map<K, V> deepEqualsMap<K, V>() =>
+ LinkedHashMap(equals: deepEquals, hashCode: deepHashCode);
+
+/// Compares two [Object]s for deep equality. This implementation differs from
+/// `package:yaml`'s deep equality notation by allowing for comparison of
+/// non-scalar map keys.
+bool deepEquals(dynamic obj1, dynamic obj2) {
+ if (obj1 is YamlNode) obj1 = obj1.value;
+ if (obj2 is YamlNode) obj2 = obj2.value;
+
+ if (obj1 is Map && obj2 is Map) {
+ return mapDeepEquals(obj1, obj2);
+ }
+
+ if (obj1 is List && obj2 is List) {
+ return listDeepEquals(obj1, obj2);
+ }
+
+ return obj1 == obj2;
+}
+
+/// Compares two [List]s for deep equality.
+bool listDeepEquals(List list1, List list2) {
+ if (list1.length != list2.length) return false;
+
+ if (list1 is YamlList) list1 = (list1 as YamlList).nodes;
+ if (list2 is YamlList) list2 = (list2 as YamlList).nodes;
+
+ for (var i = 0; i < list1.length; i++) {
+ if (!deepEquals(list1[i], list2[i])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/// Compares two [Map]s for deep equality. Differs from `package:yaml`'s deep
+/// equality notation by allowing for comparison of non-scalar map keys.
+bool mapDeepEquals(Map map1, Map map2) {
+ if (map1.length != map2.length) return false;
+
+ if (map1 is YamlList) map1 = (map1 as YamlMap).nodes;
+ if (map2 is YamlList) map2 = (map2 as YamlMap).nodes;
+
+ return map1.keys.every((key) {
+ if (!containsKey(map2, key)) return false;
+
+ /// Because two keys may be equal by deep equality but using one key on the
+ /// other map might not get a hit since they may not be both using our
+ /// [deepEqualsMap].
+ final key2 = getKey(map2, key);
+
+ if (!deepEquals(map1[key], map2[key2])) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+/// Returns a hashcode for [value] such that structures that are equal by
+/// [deepEquals] will have the same hash code.
+int deepHashCode(Object value) {
+ if (value is Map) {
+ const equality = UnorderedIterableEquality();
+ return equality.hash(value.keys.map(deepHashCode)) ^
+ equality.hash(value.values.map(deepHashCode));
+ } else if (value is Iterable) {
+ return const IterableEquality().hash(value.map(deepHashCode));
+ } else if (value is YamlScalar) {
+ return value.value.hashCode;
+ }
+
+ return value.hashCode;
+}
+
+/// Returns the [YamlNode] corresponding to the provided [key].
+YamlNode getKeyNode(YamlMap map, Object key) {
+ return map.nodes.keys.firstWhere((node) => deepEquals(node, key)) as YamlNode;
+}
+
+/// Returns the [YamlNode] after the [YamlNode] corresponding to the provided
+/// [key].
+YamlNode getNextKeyNode(YamlMap map, Object key) {
+ final keyIterator = map.nodes.keys.iterator;
+ while (keyIterator.moveNext()) {
+ if (deepEquals(keyIterator.current, key) && keyIterator.moveNext()) {
+ return keyIterator.current;
+ }
+ }
+
+ return null;
+}
+
+/// Returns the key in [map] that is equal to the provided [key] by the notion
+/// of deep equality.
+Object getKey(Map map, Object key) {
+ return map.keys.firstWhere((k) => deepEquals(k, key));
+}
+
+/// Checks if [map] has any keys equal to the provided [key] by deep equality.
+bool containsKey(Map map, Object key) {
+ return map.keys.where((node) => deepEquals(node, key)).isNotEmpty;
+}
diff --git a/lib/src/yaml_edit/errors.dart b/lib/src/yaml_edit/errors.dart
new file mode 100644
index 0000000..5d1c9a0
--- /dev/null
+++ b/lib/src/yaml_edit/errors.dart
@@ -0,0 +1,68 @@
+// 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 'package:meta/meta.dart';
+import 'package:yaml/yaml.dart';
+
+/// Error thrown when a function is passed an invalid path.
+@sealed
+class PathError extends ArgumentError {
+ /// The full path that caused the error
+ final Iterable<Object> path;
+
+ /// The subpath that caused the error
+ final Iterable<Object> subPath;
+
+ /// The last element of [path] that could be traversed.
+ YamlNode parent;
+
+ PathError(this.path, this.subPath, this.parent, [String message])
+ : super.value(subPath, 'path', message);
+
+ PathError.unexpected(this.path, String message)
+ : subPath = path,
+ super(message);
+
+ @override
+ String toString() {
+ if (message == null) {
+ var errorMessage = 'Failed to traverse to subpath $subPath!';
+
+ if (subPath.isNotEmpty) {
+ errorMessage +=
+ ' Parent $parent does not contain key or index ${subPath.last}';
+ }
+
+ return 'Invalid path: $path. $errorMessage.';
+ }
+
+ return 'Invalid path: $path. $message';
+ }
+}
+
+/// Error thrown when the path contains an alias along the way.
+///
+/// When a path contains an aliased node, the behavior becomes less well-defined
+/// because we cannot be certain if the user wishes for the change to
+/// propagate throughout all the other aliased nodes, or if the user wishes
+/// for only that particular node to be modified. As such, [AliasError] reflects
+/// the detection that our change will impact an alias, and we do not intend
+/// on supporting such changes for the foreseeable future.
+@sealed
+class AliasError extends UnsupportedError {
+ /// The path that caused the error
+ final Iterable<Object> path;
+
+ /// The anchor node of the alias
+ final YamlNode anchor;
+
+ AliasError(this.path, this.anchor)
+ : super('Encountered an alias node along $path! '
+ 'Alias nodes are nodes that refer to a previously serialized nodes, '
+ 'and are denoted by either the "*" or the "&" indicators in the '
+ 'original YAML. As the resulting behavior of mutations on these '
+ 'nodes is not well-defined, the operation will not be supported '
+ 'by this library.\n\n'
+ '${anchor.span.message('The alias was first defined here.')}');
+}
diff --git a/lib/src/yaml_edit/list_mutations.dart b/lib/src/yaml_edit/list_mutations.dart
new file mode 100644
index 0000000..9eaa949
--- /dev/null
+++ b/lib/src/yaml_edit/list_mutations.dart
@@ -0,0 +1,335 @@
+// 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 'package:yaml/yaml.dart';
+
+import 'editor.dart';
+import 'source_edit.dart';
+import 'strings.dart';
+import 'utils.dart';
+import 'wrap.dart';
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of setting the element at [index] to [newValue] when re-parsed.
+SourceEdit updateInList(
+ YamlEditor yamlEdit, YamlList list, int index, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length - 1);
+
+ final currValue = list.nodes[index];
+ var offset = currValue.span.start.offset;
+ final yaml = yamlEdit.toString();
+ String valueString;
+
+ /// We do not use [_formatNewBlock] since we want to only replace the contents
+ /// of this node while preserving comments/whitespace, while [_formatNewBlock]
+ /// produces a string representation of a new node.
+ if (list.style == CollectionStyle.BLOCK) {
+ final listIndentation = getListIndentation(yaml, list);
+ final indentation = listIndentation + getIndentation(yamlEdit);
+ final lineEnding = getLineEnding(yaml);
+ valueString = yamlEncodeBlockString(
+ wrapAsYamlNode(newValue), indentation, lineEnding);
+
+ /// We prefer the compact nested notation for collections.
+ ///
+ /// By virtue of [yamlEncodeBlockString], collections automatically
+ /// have the necessary line endings.
+ if ((newValue is List && (newValue as List).isNotEmpty) ||
+ (newValue is Map && (newValue as Map).isNotEmpty)) {
+ valueString = valueString.substring(indentation);
+ } else if (isCollection(currValue) &&
+ getStyle(currValue) == CollectionStyle.BLOCK) {
+ valueString += lineEnding;
+ }
+
+ var end = getContentSensitiveEnd(currValue);
+ if (end <= offset) {
+ offset++;
+ end = offset;
+ valueString = ' ' + valueString;
+ }
+
+ return SourceEdit(offset, end - offset, valueString);
+ } else {
+ valueString = yamlEncodeFlowString(newValue);
+ return SourceEdit(offset, currValue.span.length, valueString);
+ }
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of appending [item] to the list.
+SourceEdit appendIntoList(YamlEditor yamlEdit, YamlList list, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+
+ if (list.style == CollectionStyle.FLOW) {
+ return _appendToFlowList(yamlEdit, list, item);
+ } else {
+ return _appendToBlockList(yamlEdit, list, item);
+ }
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of inserting [item] to the list at [index].
+SourceEdit insertInList(
+ YamlEditor yamlEdit, YamlList list, int index, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length);
+
+ /// We call the append method if the user wants to append it to the end of the
+ /// list because appending requires different techniques.
+ if (index == list.length) {
+ return appendIntoList(yamlEdit, list, item);
+ } else {
+ if (list.style == CollectionStyle.FLOW) {
+ return _insertInFlowList(yamlEdit, list, index, item);
+ } else {
+ return _insertInBlockList(yamlEdit, list, index, item);
+ }
+ }
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of removing the element at [index] when re-parsed.
+SourceEdit removeInList(YamlEditor yamlEdit, YamlList list, int index) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+
+ final nodeToRemove = list.nodes[index];
+
+ if (list.style == CollectionStyle.FLOW) {
+ return _removeFromFlowList(yamlEdit, list, nodeToRemove, index);
+ } else {
+ return _removeFromBlockList(yamlEdit, list, nodeToRemove, index);
+ }
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of addition [item] into [nodes], noting that this is a flow list.
+SourceEdit _appendToFlowList(
+ YamlEditor yamlEdit, YamlList list, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+
+ final valueString = _formatNewFlow(list, item, true);
+ return SourceEdit(list.span.end.offset - 1, 0, valueString);
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of addition [item] into [nodes], noting that this is a block list.
+SourceEdit _appendToBlockList(
+ YamlEditor yamlEdit, YamlList list, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+
+ var formattedValue = _formatNewBlock(yamlEdit, list, item);
+ final yaml = yamlEdit.toString();
+ var offset = list.span.end.offset;
+
+ // Adjusts offset to after the trailing newline of the last entry, if it exists
+ if (list.isNotEmpty) {
+ final lastValueSpanEnd = list.nodes.last.span.end.offset;
+ final nextNewLineIndex = yaml.indexOf('\n', lastValueSpanEnd);
+ if (nextNewLineIndex == -1) {
+ formattedValue = getLineEnding(yaml) + formattedValue;
+ } else {
+ offset = nextNewLineIndex + 1;
+ }
+ }
+
+ return SourceEdit(offset, 0, formattedValue);
+}
+
+/// Formats [item] into a new node for block lists.
+String _formatNewBlock(YamlEditor yamlEdit, YamlList list, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+
+ final yaml = yamlEdit.toString();
+ final listIndentation = getListIndentation(yaml, list);
+ final newIndentation = listIndentation + getIndentation(yamlEdit);
+ final lineEnding = getLineEnding(yaml);
+
+ var valueString = yamlEncodeBlockString(item, newIndentation, lineEnding);
+ if (isCollection(item) && !isFlowYamlCollectionNode(item) && !isEmpty(item)) {
+ valueString = valueString.substring(newIndentation);
+ }
+ final indentedHyphen = ' ' * listIndentation + '- ';
+
+ return '$indentedHyphen$valueString$lineEnding';
+}
+
+/// Formats [item] into a new node for flow lists.
+String _formatNewFlow(YamlList list, YamlNode item, [bool isLast = false]) {
+ ArgumentError.checkNotNull(list, 'list');
+ ArgumentError.checkNotNull(isLast, 'isLast');
+
+ var valueString = yamlEncodeFlowString(item);
+ if (list.isNotEmpty) {
+ if (isLast) {
+ valueString = ', $valueString';
+ } else {
+ valueString += ', ';
+ }
+ }
+
+ return valueString;
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of inserting [item] into [nodes] at [index], noting that this is
+/// a block list.
+///
+/// [index] should be non-negative and less than or equal to [length].
+SourceEdit _insertInBlockList(
+ YamlEditor yamlEdit, YamlList list, int index, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length);
+
+ if (index == list.length) return _appendToBlockList(yamlEdit, list, item);
+
+ final formattedValue = _formatNewBlock(yamlEdit, list, item);
+
+ final currNode = list.nodes[index];
+ final currNodeStart = currNode.span.start.offset;
+ final yaml = yamlEdit.toString();
+ final start = yaml.lastIndexOf('\n', currNodeStart) + 1;
+
+ return SourceEdit(start, 0, formattedValue);
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of inserting [item] into [nodes] at [index], noting that this is
+/// a flow list.
+///
+/// [index] should be non-negative and less than or equal to [length].
+SourceEdit _insertInFlowList(
+ YamlEditor yamlEdit, YamlList list, int index, YamlNode item) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length);
+
+ if (index == list.length) return _appendToFlowList(yamlEdit, list, item);
+
+ final formattedValue = _formatNewFlow(list, item);
+
+ final yaml = yamlEdit.toString();
+ final currNode = list.nodes[index];
+ final currNodeStart = currNode.span.start.offset;
+ var start = yaml.lastIndexOf(RegExp(r',|\['), currNodeStart - 1) + 1;
+ if (yaml[start] == ' ') start++;
+
+ return SourceEdit(start, 0, formattedValue);
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of removing [nodeToRemove] from [nodes], noting that this is a
+/// block list.
+///
+/// [index] should be non-negative and less than or equal to [length].
+SourceEdit _removeFromBlockList(
+ YamlEditor yamlEdit, YamlList list, YamlNode nodeToRemove, int index) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length - 1);
+
+ var end = getContentSensitiveEnd(nodeToRemove);
+
+ /// If we are removing the last element in a block list, convert it into a
+ /// flow empty list.
+ if (list.length == 1) {
+ final start = list.span.start.offset;
+
+ return SourceEdit(start, end - start, '[]');
+ }
+
+ final yaml = yamlEdit.toString();
+ final span = nodeToRemove.span;
+
+ /// Adjust the end to clear the new line after the end too.
+ ///
+ /// We do this because we suspect that our users will want the inline
+ /// comments to disappear too.
+ final nextNewLine = yaml.indexOf('\n', end);
+ if (nextNewLine != -1) {
+ end = nextNewLine + 1;
+ }
+
+ /// If the value is empty
+ if (span.length == 0) {
+ var start = span.start.offset;
+ return SourceEdit(start, end - start, '');
+ }
+
+ /// -1 accounts for the fact that the content can start with a dash
+ var start = yaml.lastIndexOf('-', span.start.offset - 1);
+
+ /// Check if there is a `-` before the node
+ if (start > 0) {
+ final lastHyphen = yaml.lastIndexOf('-', start - 1);
+ final lastNewLine = yaml.lastIndexOf('\n', start - 1);
+ if (lastHyphen > lastNewLine) {
+ start = lastHyphen + 2;
+
+ /// If there is a `-` before the node, we need to check if we have
+ /// to update the indentation of the next node.
+ if (index < list.length - 1) {
+ /// Since [end] is currently set to the next new line after the current
+ /// node, check if we see a possible comment first, or a hyphen first.
+ /// Note that no actual content can appear here.
+ ///
+ /// We check this way because the start of a span in a block list is
+ /// the start of its value, and checking from the back leaves us
+ /// easily confused if there are comments that have dashes in them.
+ final nextHash = yaml.indexOf('#', end);
+ final nextHyphen = yaml.indexOf('-', end);
+ final nextNewLine = yaml.indexOf('\n', end);
+
+ /// If [end] is on the same line as the hyphen of the next node
+ if ((nextHash == -1 || nextHyphen < nextHash) &&
+ nextHyphen < nextNewLine) {
+ end = nextHyphen;
+ }
+ }
+ } else if (lastNewLine > lastHyphen) {
+ start = lastNewLine + 1;
+ }
+ }
+
+ return SourceEdit(start, end - start, '');
+}
+
+/// Returns a [SourceEdit] describing the change to be made on [yaml] to achieve
+/// the effect of removing [nodeToRemove] from [nodes], noting that this is a
+/// flow list.
+///
+/// [index] should be non-negative and less than or equal to [length].
+SourceEdit _removeFromFlowList(
+ YamlEditor yamlEdit, YamlList list, YamlNode nodeToRemove, int index) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(list, 'list');
+ RangeError.checkValueInInterval(index, 0, list.length - 1);
+
+ final span = nodeToRemove.span;
+ final yaml = yamlEdit.toString();
+ var start = span.start.offset;
+ var end = span.end.offset;
+
+ if (index == 0) {
+ start = yaml.lastIndexOf('[', start - 1) + 1;
+ if (index == list.length - 1) {
+ end = yaml.indexOf(']', end);
+ } else {
+ end = yaml.indexOf(',', end) + 1;
+ }
+ } else {
+ start = yaml.lastIndexOf(',', start - 1);
+ }
+
+ return SourceEdit(start, end - start, '');
+}
diff --git a/lib/src/yaml_edit/map_mutations.dart b/lib/src/yaml_edit/map_mutations.dart
new file mode 100644
index 0000000..3dd8682
--- /dev/null
+++ b/lib/src/yaml_edit/map_mutations.dart
@@ -0,0 +1,262 @@
+// 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 'package:yaml/yaml.dart';
+
+import 'editor.dart';
+import 'equality.dart';
+import 'source_edit.dart';
+import 'strings.dart';
+import 'utils.dart';
+import 'wrap.dart';
+
+/// Performs the string operation on [yaml] to achieve the effect of setting
+/// the element at [key] to [newValue] when re-parsed.
+SourceEdit updateInMap(
+ YamlEditor yamlEdit, YamlMap map, Object key, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ if (!containsKey(map, key)) {
+ final keyNode = wrapAsYamlNode(key);
+
+ if (map.style == CollectionStyle.FLOW) {
+ return _addToFlowMap(yamlEdit, map, keyNode, newValue);
+ } else {
+ return _addToBlockMap(yamlEdit, map, keyNode, newValue);
+ }
+ } else {
+ if (map.style == CollectionStyle.FLOW) {
+ return _replaceInFlowMap(yamlEdit, map, key, newValue);
+ } else {
+ return _replaceInBlockMap(yamlEdit, map, key, newValue);
+ }
+ }
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of removing
+/// the element at [key] when re-parsed.
+SourceEdit removeInMap(YamlEditor yamlEdit, YamlMap map, Object key) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ if (!containsKey(map, key)) return null;
+
+ final keyNode = getKeyNode(map, key);
+ final valueNode = map.nodes[keyNode];
+
+ if (map.style == CollectionStyle.FLOW) {
+ return _removeFromFlowMap(yamlEdit, map, keyNode, valueNode);
+ } else {
+ return _removeFromBlockMap(yamlEdit, map, keyNode, valueNode);
+ }
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of adding
+/// the [key]:[newValue] pair when reparsed, bearing in mind that this is a
+/// block map.
+SourceEdit _addToBlockMap(
+ YamlEditor yamlEdit, YamlMap map, Object key, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ final yaml = yamlEdit.toString();
+ final newIndentation =
+ getMapIndentation(yaml, map) + getIndentation(yamlEdit);
+ final keyString = yamlEncodeFlowString(wrapAsYamlNode(key));
+ final lineEnding = getLineEnding(yaml);
+
+ var valueString = yamlEncodeBlockString(newValue, newIndentation, lineEnding);
+ if (isCollection(newValue) &&
+ !isFlowYamlCollectionNode(newValue) &&
+ !isEmpty(newValue)) {
+ valueString = '$lineEnding$valueString';
+ }
+
+ var formattedValue = ' ' * getMapIndentation(yaml, map) + '$keyString: ';
+ var offset = map.span.end.offset;
+
+ final insertionIndex = getMapInsertionIndex(map, keyString);
+
+ if (map.isNotEmpty) {
+ /// Adjusts offset to after the trailing newline of the last entry, if it
+ /// exists
+ if (insertionIndex == map.length) {
+ final lastValueSpanEnd = getContentSensitiveEnd(map.nodes.values.last);
+ final nextNewLineIndex = yaml.indexOf('\n', lastValueSpanEnd);
+
+ if (nextNewLineIndex != -1) {
+ offset = nextNewLineIndex + 1;
+ } else {
+ formattedValue = lineEnding + formattedValue;
+ }
+ } else {
+ final keyAtIndex = map.nodes.keys.toList()[insertionIndex] as YamlNode;
+ final keySpanStart = keyAtIndex.span.start.offset;
+ final prevNewLineIndex = yaml.lastIndexOf('\n', keySpanStart);
+
+ offset = prevNewLineIndex + 1;
+ }
+ }
+
+ formattedValue += valueString + lineEnding;
+
+ return SourceEdit(offset, 0, formattedValue);
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of adding
+/// the [key]:[newValue] pair when reparsed, bearing in mind that this is a flow
+/// map.
+SourceEdit _addToFlowMap(
+ YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ final keyString = yamlEncodeFlowString(keyNode);
+ final valueString = yamlEncodeFlowString(newValue);
+
+ // The -1 accounts for the closing bracket.
+ if (map.isEmpty) {
+ return SourceEdit(map.span.end.offset - 1, 0, '$keyString: $valueString');
+ }
+
+ final insertionIndex = getMapInsertionIndex(map, keyString);
+
+ if (insertionIndex == map.length) {
+ return SourceEdit(map.span.end.offset - 1, 0, ', $keyString: $valueString');
+ }
+
+ final insertionOffset =
+ (map.nodes.keys.toList()[insertionIndex] as YamlNode).span.start.offset;
+
+ return SourceEdit(insertionOffset, 0, '$keyString: $valueString, ');
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of replacing
+/// the value at [key] with [newValue] when reparsed, bearing in mind that this
+/// is a block map.
+SourceEdit _replaceInBlockMap(
+ YamlEditor yamlEdit, YamlMap map, Object key, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ final yaml = yamlEdit.toString();
+ final lineEnding = getLineEnding(yaml);
+ final newIndentation =
+ getMapIndentation(yaml, map) + getIndentation(yamlEdit);
+
+ final keyNode = getKeyNode(map, key);
+ var valueAsString = yamlEncodeBlockString(
+ wrapAsYamlNode(newValue), newIndentation, lineEnding);
+ if (isCollection(newValue) &&
+ !isFlowYamlCollectionNode(newValue) &&
+ !isEmpty(newValue)) {
+ valueAsString = lineEnding + valueAsString;
+ }
+
+ /// +1 accounts for the colon
+ final start = keyNode.span.end.offset + 1;
+ var end = getContentSensitiveEnd(map.nodes[key]);
+
+ /// `package:yaml` parses empty nodes in a way where the start/end of the
+ /// empty value node is the end of the key node, so we have to adjust for
+ /// this.
+ if (end < start) end = start + 1;
+
+ return SourceEdit(start, end - start, ' ' + valueAsString);
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of replacing
+/// the value at [key] with [newValue] when reparsed, bearing in mind that this
+/// is a flow map.
+SourceEdit _replaceInFlowMap(
+ YamlEditor yamlEdit, YamlMap map, Object key, YamlNode newValue) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+
+ final valueSpan = map.nodes[key].span;
+ final valueString = yamlEncodeFlowString(newValue);
+
+ return SourceEdit(valueSpan.start.offset, valueSpan.length, valueString);
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of removing
+/// the [key] from the map, bearing in mind that this is a block map.
+SourceEdit _removeFromBlockMap(
+ YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode valueNode) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+ ArgumentError.checkNotNull(keyNode, 'keyNode');
+ ArgumentError.checkNotNull(valueNode, 'valueNode');
+
+ final keySpan = keyNode.span;
+ var end = getContentSensitiveEnd(valueNode);
+ final yaml = yamlEdit.toString();
+
+ if (map.length == 1) {
+ final start = map.span.start.offset;
+ return SourceEdit(start, end - start, '{}');
+ }
+
+ var start = keySpan.start.offset;
+
+ /// Adjust the end to clear the new line after the end too.
+ ///
+ /// We do this because we suspect that our users will want the inline
+ /// comments to disappear too.
+ final nextNewLine = yaml.indexOf('\n', end);
+ if (nextNewLine != -1) {
+ end = nextNewLine + 1;
+ }
+
+ final nextNode = getNextKeyNode(map, keyNode);
+
+ if (start > 0) {
+ final lastHyphen = yaml.lastIndexOf('-', start - 1);
+ final lastNewLine = yaml.lastIndexOf('\n', start - 1);
+ if (lastHyphen > lastNewLine) {
+ start = lastHyphen + 2;
+
+ /// If there is a `-` before the node, and the end is on the same line
+ /// as the next node, we need to add the necessary offset to the end to
+ /// make sure the next node has the correct indentation.
+ if (nextNode != null &&
+ nextNode.span.start.offset - end <= nextNode.span.start.column) {
+ end += nextNode.span.start.column;
+ }
+ } else if (lastNewLine > lastHyphen) {
+ start = lastNewLine + 1;
+ }
+ }
+
+ return SourceEdit(start, end - start, '');
+}
+
+/// Performs the string operation on [yaml] to achieve the effect of removing
+/// the [key] from the map, bearing in mind that this is a flow map.
+SourceEdit _removeFromFlowMap(
+ YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode valueNode) {
+ ArgumentError.checkNotNull(yamlEdit, 'yamlEdit');
+ ArgumentError.checkNotNull(map, 'map');
+ ArgumentError.checkNotNull(keyNode, 'keyNode');
+ ArgumentError.checkNotNull(valueNode, 'valueNode');
+
+ var start = keyNode.span.start.offset;
+ var end = valueNode.span.end.offset;
+ final yaml = yamlEdit.toString();
+
+ if (deepEquals(keyNode, map.keys.first)) {
+ start = yaml.lastIndexOf('{', start - 1) + 1;
+
+ if (deepEquals(keyNode, map.keys.last)) {
+ end = yaml.indexOf('}', end);
+ } else {
+ end = yaml.indexOf(',', end) + 1;
+ }
+ } else {
+ start = yaml.lastIndexOf(',', start - 1);
+ }
+
+ return SourceEdit(start, end - start, '');
+}
diff --git a/lib/src/yaml_edit/source_edit.dart b/lib/src/yaml_edit/source_edit.dart
new file mode 100644
index 0000000..d52373b
--- /dev/null
+++ b/lib/src/yaml_edit/source_edit.dart
@@ -0,0 +1,143 @@
+// 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 'package:meta/meta.dart';
+
+/// A class representing a change on a [String], intended to be compatible with
+/// `package:analysis_server`'s [SourceEdit].
+///
+/// For example, changing a string from
+/// ```
+/// foo: foobar
+/// ```
+/// to
+/// ```
+/// foo: barbar
+/// ```
+/// will be represented by `SourceEdit(offset: 4, length: 3, replacement: 'bar')`
+@sealed
+class SourceEdit {
+ /// The offset from the start of the string where the modification begins.
+ final int offset;
+
+ /// The length of the substring to be replaced.
+ final int length;
+
+ /// The replacement string to be used.
+ final String replacement;
+
+ /// Creates a new [SourceEdit] instance. [offset], [length] and [replacement]
+ /// must be non-null, and [offset] and [length] must be non-negative.
+ factory SourceEdit(int offset, int length, String replacement) =>
+ SourceEdit._(offset, length, replacement);
+
+ SourceEdit._(this.offset, this.length, this.replacement) {
+ ArgumentError.checkNotNull(offset, 'offset');
+ ArgumentError.checkNotNull(length, 'length');
+ ArgumentError.checkNotNull(replacement, 'replacement');
+ RangeError.checkNotNegative(offset);
+ RangeError.checkNotNegative(length);
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (other is SourceEdit) {
+ return offset == other.offset &&
+ length == other.length &&
+ replacement == other.replacement;
+ }
+
+ return false;
+ }
+
+ @override
+ int get hashCode => offset.hashCode ^ length.hashCode ^ replacement.hashCode;
+
+ /// Constructs a SourceEdit from JSON.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final edit = {
+ /// 'offset': 1,
+ /// 'length': 2,
+ /// 'replacement': 'replacement string'
+ /// };
+ ///
+ /// final sourceEdit = SourceEdit.fromJson(edit);
+ /// ```
+ factory SourceEdit.fromJson(Map<String, dynamic> json) {
+ ArgumentError.checkNotNull(json, 'json');
+
+ if (json is Map) {
+ final offset = json['offset'];
+ final length = json['length'];
+ final replacement = json['replacement'];
+
+ if (offset is int && length is int && replacement is String) {
+ return SourceEdit(offset, length, replacement);
+ }
+ }
+ throw FormatException('Invalid JSON passed to SourceEdit');
+ }
+
+ /// Encodes this object as JSON-compatible structure.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// import 'dart:convert' show jsonEncode;
+ ///
+ /// final edit = SourceEdit(offset, length, 'replacement string');
+ /// final jsonString = jsonEncode(edit.toJson());
+ /// print(jsonString);
+ /// ```
+ Map<String, dynamic> toJson() {
+ return {'offset': offset, 'length': length, 'replacement': replacement};
+ }
+
+ @override
+ String toString() => 'SourceEdit($offset, $length, "$replacement")';
+
+ /// Applies a series of [SourceEdit]s to an original string, and return the
+ /// final output.
+ ///
+ /// [edits] should be in order i.e. the first [SourceEdit] in [edits] should
+ /// be the first edit applied to [original].
+ ///
+ /// **Example:**
+ /// ```dart
+ /// const original = 'YAML: YAML';
+ /// final sourceEdits = [
+ /// SourceEdit(6, 4, "YAML Ain't Markup Language"),
+ /// SourceEdit(6, 4, "YAML Ain't Markup Language"),
+ /// SourceEdit(0, 4, "YAML Ain't Markup Language")
+ /// ];
+ /// final result = SourceEdit.applyAll(original, sourceEdits);
+ /// ```
+ /// **Expected result:**
+ /// ```dart
+ /// "YAML Ain't Markup Language: YAML Ain't Markup Language Ain't Markup
+ /// Language"
+ /// ```
+ static String applyAll(String original, Iterable<SourceEdit> edits) {
+ ArgumentError.checkNotNull(original, 'original');
+ ArgumentError.checkNotNull(edits, 'edits');
+
+ return edits.fold(original, (current, edit) => edit.apply(current));
+ }
+
+ /// Applies one [SourceEdit]s to an original string, and return the final
+ /// output.
+ ///
+ /// **Example:**
+ /// ```dart
+ /// final edit = SourceEdit(4, 3, 'bar');
+ /// final originalString = 'foo: foobar';
+ /// print(edit.apply(originalString)); // 'foo: barbar'
+ /// ```
+ String apply(String original) {
+ ArgumentError.checkNotNull(original, 'original');
+
+ return original.replaceRange(offset, offset + length, replacement);
+ }
+}
diff --git a/lib/src/yaml_edit/strings.dart b/lib/src/yaml_edit/strings.dart
new file mode 100644
index 0000000..faebe8a
--- /dev/null
+++ b/lib/src/yaml_edit/strings.dart
@@ -0,0 +1,311 @@
+// 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 'package:yaml/yaml.dart';
+import 'utils.dart';
+
+/// Given [value], tries to format it into a plain string recognizable by YAML.
+/// If it fails, it defaults to returning a double-quoted string.
+///
+/// Not all values can be formatted into a plain string. If the string contains
+/// an escape sequence, it can only be detected when in a double-quoted
+/// sequence. Plain strings may also be misinterpreted by the YAML parser (e.g.
+/// ' null').
+String _tryYamlEncodePlain(Object value) {
+ if (value is YamlNode) {
+ AssertionError(
+ 'YamlNodes should not be passed directly into getSafeString!');
+ }
+
+ assertValidScalar(value);
+
+ if (value is String) {
+ /// If it contains a dangerous character we want to wrap the result with
+ /// double quotes because the double quoted style allows for arbitrary
+ /// strings with "\" escape sequences.
+ ///
+ /// See 7.3.1 Double-Quoted Style
+ /// https://yaml.org/spec/1.2/spec.html#id2787109
+ if (isDangerousString(value)) {
+ return _yamlEncodeDoubleQuoted(value);
+ }
+
+ return value;
+ }
+
+ return value.toString();
+}
+
+/// Checks if [string] has unprintable characters according to
+/// [unprintableCharCodes].
+bool _hasUnprintableCharacters(String string) {
+ ArgumentError.checkNotNull(string, 'string');
+
+ final codeUnits = string.codeUnits;
+
+ for (final key in unprintableCharCodes.keys) {
+ if (codeUnits.contains(key)) return true;
+ }
+
+ return false;
+}
+
+/// Generates a YAML-safe double-quoted string based on [string], escaping the
+/// list of characters as defined by the YAML 1.2 spec.
+///
+/// See 5.7 Escaped Characters https://yaml.org/spec/1.2/spec.html#id2776092
+String _yamlEncodeDoubleQuoted(String string) {
+ ArgumentError.checkNotNull(string, 'string');
+
+ final buffer = StringBuffer();
+ for (final codeUnit in string.codeUnits) {
+ if (doubleQuoteEscapeChars[codeUnit] != null) {
+ buffer.write(doubleQuoteEscapeChars[codeUnit]);
+ } else {
+ buffer.writeCharCode(codeUnit);
+ }
+ }
+
+ return '"$buffer"';
+}
+
+/// Generates a YAML-safe single-quoted string. Automatically escapes
+/// single-quotes.
+///
+/// It is important that we ensure that [string] is free of unprintable
+/// characters by calling [assertValidScalar] before invoking this function.
+String _tryYamlEncodeSingleQuoted(String string) {
+ ArgumentError.checkNotNull(string, 'string');
+
+ final result = string.replaceAll('\'', '\'\'');
+ return '\'$result\'';
+}
+
+/// Generates a YAML-safe folded string.
+///
+/// It is important that we ensure that [string] is free of unprintable
+/// characters by calling [assertValidScalar] before invoking this function.
+String _tryYamlEncodeFolded(String string, int indentation, String lineEnding) {
+ ArgumentError.checkNotNull(string, 'string');
+ ArgumentError.checkNotNull(indentation, 'indentation');
+ ArgumentError.checkNotNull(lineEnding, 'lineEnding');
+
+ String result;
+
+ final trimmedString = string.trimRight();
+ final removedPortion = string.substring(trimmedString.length);
+
+ if (removedPortion.contains('\n')) {
+ result = '>+\n' + ' ' * indentation;
+ } else {
+ result = '>-\n' + ' ' * indentation;
+ }
+
+ /// Duplicating the newline for folded strings preserves it in YAML.
+ /// Assumes the user did not try to account for windows documents by using
+ /// `\r\n` already
+ return result +
+ trimmedString.replaceAll('\n', lineEnding * 2 + ' ' * indentation) +
+ removedPortion;
+}
+
+/// Generates a YAML-safe literal string.
+///
+/// It is important that we ensure that [string] is free of unprintable
+/// characters by calling [assertValidScalar] before invoking this function.
+String _tryYamlEncodeLiteral(
+ String string, int indentation, String lineEnding) {
+ ArgumentError.checkNotNull(string, 'string');
+ ArgumentError.checkNotNull(indentation, 'indentation');
+ ArgumentError.checkNotNull(lineEnding, 'lineEnding');
+
+ final result = '|-\n$string';
+
+ /// Assumes the user did not try to account for windows documents by using
+ /// `\r\n` already
+ return result.replaceAll('\n', lineEnding + ' ' * indentation);
+}
+
+/// Returns [value] with the necessary formatting applied in a flow context
+/// if possible.
+///
+/// If [value] is a [YamlScalar], we try to respect its [style] parameter where
+/// possible. Certain cases make this impossible (e.g. a plain string scalar that
+/// starts with '>'), in which case we will produce [value] with default styling
+/// options.
+String _yamlEncodeFlowScalar(YamlNode value) {
+ if (value is YamlScalar) {
+ assertValidScalar(value.value);
+
+ if (value.value is String) {
+ if (_hasUnprintableCharacters(value.value) ||
+ value.style == ScalarStyle.DOUBLE_QUOTED) {
+ return _yamlEncodeDoubleQuoted(value.value);
+ }
+
+ if (value.style == ScalarStyle.SINGLE_QUOTED) {
+ return _tryYamlEncodeSingleQuoted(value.value);
+ }
+ }
+
+ return _tryYamlEncodePlain(value.value);
+ }
+
+ assertValidScalar(value);
+ return _tryYamlEncodePlain(value);
+}
+
+/// Returns [value] with the necessary formatting applied in a block context
+/// if possible.
+///
+/// If [value] is a [YamlScalar], we try to respect its [style] parameter where
+/// possible. Certain cases make this impossible (e.g. a folded string scalar
+/// 'null'), in which case we will produce [value] with default styling
+/// options.
+String yamlEncodeBlockScalar(
+ YamlNode value, int indentation, String lineEnding) {
+ ArgumentError.checkNotNull(indentation, 'indentation');
+ ArgumentError.checkNotNull(lineEnding, 'lineEnding');
+
+ if (value is YamlScalar) {
+ assertValidScalar(value.value);
+
+ if (value.value is String) {
+ if (_hasUnprintableCharacters(value.value)) {
+ return _yamlEncodeDoubleQuoted(value.value);
+ }
+
+ if (value.style == ScalarStyle.SINGLE_QUOTED) {
+ return _tryYamlEncodeSingleQuoted(value.value);
+ }
+
+ // Strings with only white spaces will cause a misparsing
+ if (value.value.trim().length == value.value.length &&
+ value.value.length != 0) {
+ if (value.style == ScalarStyle.FOLDED) {
+ return _tryYamlEncodeFolded(value.value, indentation, lineEnding);
+ }
+
+ if (value.style == ScalarStyle.LITERAL) {
+ return _tryYamlEncodeLiteral(value.value, indentation, lineEnding);
+ }
+ }
+ }
+
+ return _tryYamlEncodePlain(value.value);
+ }
+
+ assertValidScalar(value);
+
+ /// The remainder of the possibilities are similar to how [getFlowScalar]
+ /// treats [value].
+ return _yamlEncodeFlowScalar(value);
+}
+
+/// Returns [value] with the necessary formatting applied in a flow context.
+///
+/// If [value] is a [YamlNode], we try to respect its [style] parameter where
+/// possible. Certain cases make this impossible (e.g. a plain string scalar
+/// that starts with '>', a child having a block style parameters), in which
+/// case we will produce [value] with default styling options.
+String yamlEncodeFlowString(YamlNode value) {
+ if (value is YamlList) {
+ final list = value.nodes;
+
+ final safeValues = list.map(yamlEncodeFlowString);
+ return '[' + safeValues.join(', ') + ']';
+ } else if (value is YamlMap) {
+ final safeEntries = value.nodes.entries.map((entry) {
+ final safeKey = yamlEncodeFlowString(entry.key);
+ final safeValue = yamlEncodeFlowString(entry.value);
+ return '$safeKey: $safeValue';
+ });
+
+ return '{' + safeEntries.join(', ') + '}';
+ }
+
+ return _yamlEncodeFlowScalar(value);
+}
+
+/// Returns [value] with the necessary formatting applied in a block context.
+///
+/// If [value] is a [YamlNode], we respect its [style] parameter.
+String yamlEncodeBlockString(
+ YamlNode value, int indentation, String lineEnding) {
+ ArgumentError.checkNotNull(indentation, 'indentation');
+ ArgumentError.checkNotNull(lineEnding, 'lineEnding');
+
+ const additionalIndentation = 2;
+
+ if (!isBlockNode(value)) return yamlEncodeFlowString(value);
+
+ final newIndentation = indentation + additionalIndentation;
+
+ if (value is YamlList) {
+ if (value.isEmpty) return ' ' * indentation + '[]';
+
+ Iterable<String> safeValues;
+
+ final children = value.nodes;
+
+ safeValues = children.map((child) {
+ var valueString =
+ yamlEncodeBlockString(child, newIndentation, lineEnding);
+ if (isCollection(child) && !isFlowYamlCollectionNode(child)) {
+ valueString = valueString.substring(newIndentation);
+ }
+
+ return ' ' * indentation + '- $valueString';
+ });
+
+ return safeValues.join(lineEnding);
+ } else if (value is YamlMap) {
+ if (value.isEmpty) return ' ' * indentation + '{}';
+
+ return value.nodes.entries.map((entry) {
+ final safeKey = yamlEncodeFlowString(entry.key);
+ final formattedKey = ' ' * indentation + safeKey;
+ final formattedValue =
+ yamlEncodeBlockString(entry.value, newIndentation, lineEnding);
+
+ if (isCollection(entry.value)) {
+ return formattedKey + ':\n' + formattedValue;
+ }
+
+ return formattedKey + ': ' + formattedValue;
+ }).join(lineEnding);
+ }
+
+ return yamlEncodeBlockScalar(value, newIndentation, lineEnding);
+}
+
+/// List of unprintable characters.
+///
+/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092
+final Map<int, String> unprintableCharCodes = {
+ 0: '\\0', // Escaped ASCII null (#x0) character.
+ 7: '\\a', // Escaped ASCII bell (#x7) character.
+ 8: '\\b', // Escaped ASCII backspace (#x8) character.
+ 11: '\\v', // Escaped ASCII vertical tab (#xB) character.
+ 12: '\\f', // Escaped ASCII form feed (#xC) character.
+ 13: '\\r', // Escaped ASCII carriage return (#xD) character. Line Break.
+ 27: '\\e', // Escaped ASCII escape (#x1B) character.
+ 133: '\\N', // Escaped Unicode next line (#x85) character.
+ 160: '\\_', // Escaped Unicode non-breaking space (#xA0) character.
+ 8232: '\\L', // Escaped Unicode line separator (#x2028) character.
+ 8233: '\\P', // Escaped Unicode paragraph separator (#x2029) character.
+};
+
+/// List of escape characters. In particular, \x32 is not included because it
+/// can be processed normally.
+///
+/// See 5.7 Escape Characters https://yaml.org/spec/1.2/spec.html#id2776092
+final Map<int, String> doubleQuoteEscapeChars = {
+ ...unprintableCharCodes,
+ 9: '\\t', // Escaped ASCII horizontal tab (#x9) character. Printable
+ 10: '\\n', // Escaped ASCII line feed (#xA) character. Line Break.
+ 34: '\\"', // Escaped ASCII double quote (#x22).
+ 47: '\\/', // Escaped ASCII slash (#x2F), for JSON compatibility.
+ 92: '\\\\', // Escaped ASCII back slash (#x5C).
+};
diff --git a/lib/src/yaml_edit/utils.dart b/lib/src/yaml_edit/utils.dart
new file mode 100644
index 0000000..b4e64e6
--- /dev/null
+++ b/lib/src/yaml_edit/utils.dart
@@ -0,0 +1,269 @@
+// 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 'package:source_span/source_span.dart';
+import 'package:yaml/yaml.dart';
+
+import 'editor.dart';
+
+/// 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) {
+ ArgumentError.checkNotNull(string, 'string');
+
+ try {
+ if (loadYamlNode(string).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) {
+ ArgumentError.checkNotNull(node, '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) {
+ ArgumentError.checkNotNull(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) {
+ if (value is YamlList || value is YamlMap) {
+ return (value as dynamic).style == CollectionStyle.FLOW;
+ }
+
+ return false;
+}
+
+/// 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) {
+ ArgumentError.checkNotNull(map, 'map');
+
+ final keys = map.nodes.keys.map((k) => k.toString()).toList();
+
+ 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) > 0);
+
+ if (insertionIndex != -1) return insertionIndex;
+
+ return map.length;
+}
+
+/// Returns the [style] property of [target], if it is a [YamlNode]. Otherwise return null.
+Object getStyle(Object target) {
+ if (target is YamlNode) {
+ return (target as dynamic).style;
+ }
+
+ return null;
+}
+
+/// Returns the detected indentation step used in [yaml], 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 [yaml].
+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) {
+ ArgumentError.checkNotNull(list, '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 lastNewLine = yaml.lastIndexOf('\n', lastSpanOffset - 1);
+ final lastHyphen = yaml.lastIndexOf('-', lastSpanOffset - 1);
+
+ if (lastNewLine == -1) return lastHyphen;
+
+ 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) {
+ ArgumentError.checkNotNull(map, '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 stricly 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';
+}
diff --git a/lib/src/yaml_edit/wrap.dart b/lib/src/yaml_edit/wrap.dart
new file mode 100644
index 0000000..5585ea6
--- /dev/null
+++ b/lib/src/yaml_edit/wrap.dart
@@ -0,0 +1,185 @@
+// 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:collection' as collection;
+import 'package:collection/collection.dart';
+import 'package:source_span/source_span.dart';
+import 'package:yaml/yaml.dart';
+
+import 'equality.dart';
+import 'utils.dart';
+
+/// Returns a new [YamlMap] constructed by applying [update] onto the [nodes]
+/// of this [YamlMap].
+YamlMap updatedYamlMap(YamlMap map, Function(Map) update) {
+ ArgumentError.checkNotNull(map, 'map');
+
+ final dummyMap = deepEqualsMap();
+ dummyMap.addAll(map.nodes);
+
+ update(dummyMap);
+
+ return wrapAsYamlNode(dummyMap);
+}
+
+/// Wraps [value] into a [YamlNode].
+///
+/// [Map]s, [List]s and Scalars will be wrapped as [YamlMap]s, [YamlList]s,
+/// and [YamlScalar]s respectively. If [collectionStyle]/[scalarStyle] is
+/// defined, and [value] is a collection or scalar, the wrapped [YamlNode] will
+/// have the respective style, otherwise it defaults to the ANY style.
+///
+/// If a [YamlNode] is passed in, no further wrapping will be done, and the
+/// [collectionStyle]/[scalarStyle] will not be applied.
+YamlNode wrapAsYamlNode(Object value,
+ {CollectionStyle collectionStyle = CollectionStyle.ANY,
+ ScalarStyle scalarStyle = ScalarStyle.ANY}) {
+ if (value is YamlScalar) {
+ assertValidScalar(value.value);
+ return value;
+ } else if (value is YamlList) {
+ for (final item in value.nodes) {
+ wrapAsYamlNode(item);
+ }
+
+ return value;
+ } else if (value is YamlMap) {
+ /// Both [entry.key] and [entry.values] are guaranteed to be [YamlNode]s,
+ /// so running this will just assert that they are valid scalars.
+ for (final entry in value.nodes.entries) {
+ wrapAsYamlNode(entry.key);
+ wrapAsYamlNode(entry.value);
+ }
+
+ return value;
+ } else if (value is Map) {
+ ArgumentError.checkNotNull(collectionStyle, 'collectionStyle');
+ return YamlMapWrap(value, collectionStyle: collectionStyle);
+ } else if (value is List) {
+ ArgumentError.checkNotNull(collectionStyle, 'collectionStyle');
+ return YamlListWrap(value, collectionStyle: collectionStyle);
+ } else {
+ assertValidScalar(value);
+
+ ArgumentError.checkNotNull(scalarStyle, 'scalarStyle');
+ return YamlScalarWrap(value, style: scalarStyle);
+ }
+}
+
+/// Internal class that allows us to define a constructor on [YamlScalar]
+/// which takes in [style] as an argument.
+class YamlScalarWrap implements YamlScalar {
+ /// The [ScalarStyle] to be used for the scalar.
+ @override
+ final ScalarStyle style;
+
+ @override
+ final SourceSpan span;
+
+ @override
+ final dynamic value;
+
+ YamlScalarWrap(this.value, {this.style = ScalarStyle.ANY, Object sourceUrl})
+ : span = shellSpan(sourceUrl) {
+ ArgumentError.checkNotNull(style, 'scalarStyle');
+ }
+
+ @override
+ String toString() => value.toString();
+}
+
+/// Internal class that allows us to define a constructor on [YamlMap]
+/// which takes in [style] as an argument.
+class YamlMapWrap
+ with collection.MapMixin, UnmodifiableMapMixin
+ implements YamlMap {
+ /// The [CollectionStyle] to be used for the map.
+ @override
+ final CollectionStyle style;
+
+ @override
+ final Map<dynamic, YamlNode> nodes;
+
+ @override
+ final SourceSpan span;
+
+ factory YamlMapWrap(Map dartMap,
+ {CollectionStyle collectionStyle = CollectionStyle.ANY,
+ Object sourceUrl}) {
+ ArgumentError.checkNotNull(collectionStyle, 'collectionStyle');
+
+ final wrappedMap = deepEqualsMap<dynamic, YamlNode>();
+
+ for (final entry in dartMap.entries) {
+ final wrappedKey = wrapAsYamlNode(entry.key);
+ final wrappedValue = wrapAsYamlNode(entry.value);
+ wrappedMap[wrappedKey] = wrappedValue;
+ }
+
+ return YamlMapWrap._(wrappedMap,
+ style: collectionStyle, sourceUrl: sourceUrl);
+ }
+
+ YamlMapWrap._(this.nodes,
+ {CollectionStyle style = CollectionStyle.ANY, Object sourceUrl})
+ : span = shellSpan(sourceUrl),
+ style = nodes.isEmpty ? CollectionStyle.FLOW : style;
+
+ @override
+ dynamic operator [](Object key) => nodes[key]?.value;
+
+ @override
+ Iterable get keys => nodes.keys.map((node) => node.value);
+
+ @override
+ Map get value => this;
+}
+
+/// Internal class that allows us to define a constructor on [YamlList]
+/// which takes in [style] as an argument.
+class YamlListWrap with collection.ListMixin implements YamlList {
+ /// The [CollectionStyle] to be used for the list.
+ @override
+ final CollectionStyle style;
+
+ @override
+ final List<YamlNode> nodes;
+
+ @override
+ final SourceSpan span;
+
+ @override
+ int get length => nodes.length;
+
+ @override
+ set length(int index) {
+ throw UnsupportedError('Cannot modify an unmodifiable List');
+ }
+
+ factory YamlListWrap(List dartList,
+ {CollectionStyle collectionStyle = CollectionStyle.ANY,
+ Object sourceUrl}) {
+ ArgumentError.checkNotNull(collectionStyle, 'collectionStyle');
+
+ final wrappedList = dartList.map(wrapAsYamlNode).toList();
+ return YamlListWrap._(wrappedList,
+ style: collectionStyle, sourceUrl: sourceUrl);
+ }
+
+ YamlListWrap._(this.nodes,
+ {CollectionStyle style = CollectionStyle.ANY, Object sourceUrl})
+ : span = shellSpan(sourceUrl),
+ style = nodes.isEmpty ? CollectionStyle.FLOW : style;
+
+ @override
+ dynamic operator [](int index) => nodes[index].value;
+
+ @override
+ operator []=(int index, value) {
+ throw UnsupportedError('Cannot modify an unmodifiable List');
+ }
+
+ @override
+ List get value => this;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 076e303..30b464d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -28,7 +28,6 @@
source_span: ^1.4.0
stack_trace: ^1.0.0
yaml: ^2.2.0
- yaml_edit: ^1.0.1
dev_dependencies:
shelf_test_handler: ^1.0.0