// 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:source_span/source_span.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 modifying [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` /// 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 /// [ArgumentError] 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 _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 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 _aliases = {}; /// Returns the current YAML string. @override String toString() => _yaml; factory YamlEditor(String yaml) => YamlEditor._(yaml); YamlEditor._(this._yaml) : _contents = loadYamlNode(_yaml) { _initialize(); } /// Traverses the YAML tree formed to detect alias nodes. void _initialize() { _aliases = {}; /// Performs a DFS on [_contents] to detect alias nodes. final visited = {}; void collectAliases(YamlNode node) { if (visited.add(node)) { if (node is YamlMap) { node.nodes.forEach((key, value) { collectAliases(key as YamlNode); 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 a default value when [path] does not point to a value in the /// [YamlNode]-tree, simply pass `orElse: () => ...`. /// /// **Example:** (using orElse) /// ```dart /// final myYamlEditor('{"key": "value"}'); /// final node = myYamlEditor.valueAt( /// ['invalid', 'path'], /// orElse: () => wrapAsYamlNode(null), /// ); /// print(node.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 path, {YamlNode Function()? orElse}) { 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. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **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 path, Object? value) { 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, yamlEncodeBlock(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) { if (keyOrIndex is! int) { throw PathError(path, path, parentNode); } 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. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **Example:** /// ```dart /// final doc = YamlEditor('[0, 1]'); /// doc.appendToList([], 2); // [0, 1, 2] /// ``` void appendToList(Iterable path, Object? value) { 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. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **Example:** /// ```dart /// final doc = YamlEditor('[1, 2]'); /// doc.prependToList([], 0); // [0, 1, 2] /// ``` void prependToList(Iterable path, Object? value) { 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. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **Example:** /// ```dart /// final doc = YamlEditor('[0, 2]'); /// doc.insertIntoList([], 1, 1); // [0, 1, 2] /// ``` void insertIntoList(Iterable path, int index, Object? value) { 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. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **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 spliceList(Iterable path, int index, int deleteCount, Iterable 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 scenarios 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 an [ArgumentError] if [path] is invalid. /// /// Throws an [AliasException] if a node on [path] is an alias or anchor. /// /// **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 path) { late SourceEdit edit; late YamlNode expectedNode; final nodeToRemove = _traverse(path, checkAlias: true); if (path.isEmpty) { edit = SourceEdit(0, _yaml.length, ''); expectedNode = wrapAsYamlNode(null); /// Parsing an empty YAML document returns YamlScalar with value `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 as int); 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 [AliasException] if an aliased node is /// encountered. YamlNode _traverse(Iterable path, {bool checkAlias = false, YamlNode Function()? orElse}) { 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 AliasException(path, currentNode); } if (currentNode is YamlList) { final list = currentNode; if (!isValidIndex(keyOrIndex, list.length)) { return _pathErrorOrElse(path, path.take(i + 1), list, orElse); } currentNode = list.nodes[keyOrIndex as int]; } else if (currentNode is YamlMap) { final map = currentNode; 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 AliasException(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 path, Iterable 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 path, [YamlNode? node]) { if (node == null) return _assertNoChildAlias(path, _traverse(path)); if (_aliases.contains(node)) throw AliasException(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 AliasException(path, keyList[i] as YamlNode); } _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 [AliasException] will be /// thrown. YamlList _traverseToList(Iterable path, {bool checkAlias = false}) { final possibleList = _traverse(path, checkAlias: checkAlias); 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 path, YamlNode expectedNode) { final expectedTree = _deepModify(_contents, path, [], expectedNode); final initialYaml = _yaml; _yaml = edit.apply(_yaml); try { _initialize(); } on YamlException { throw createAssertionError( 'Failed to produce valid YAML after modification.', initialYaml, _yaml); } final actualTree = withYamlWarningCallback(() => loadYamlNode(_yaml)); if (!deepEquals(actualTree, expectedTree)) { throw createAssertionError( 'Modification did not result in expected result.', initialYaml, _yaml); } _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 path, Iterable subPath, YamlNode expectedNode) { 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 as int] = _deepModify( tree.nodes[keyOrIndex], path, path.take(subPath.length + 1), expectedNode)); } if (tree is YamlMap) { return updatedYamlMap( tree, (nodes) => nodes[keyOrIndex] = _deepModify( nodes[keyOrIndex] as YamlNode, path, path.take(subPath.length + 1), expectedNode)); } /// Should not ever reach here. throw PathError(path, subPath, tree); } }