| // 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 'alias_behavior.dart'; |
| import 'char_codes.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<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 |
| /// [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<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 |
| /// |
| /// Defines how mutations behave when encountering YAML alias references or |
| /// anchor definition nodes. Defaults to [AliasBehavior.disallow], which |
| /// throws an [AliasException] on any modification touching an alias or |
| /// anchor. |
| final AliasBehavior aliasBehavior; |
| |
| /// See 7.1 Alias Nodes: https://yaml.org/spec/1.2/spec.html#id2786196 |
| Set<YamlNode> _aliases = {}; |
| |
| /// Maps each aliased [YamlNode] to its anchor definition path (the first |
| /// path where it was encountered during AST initialization DFS). |
| Map<YamlNode, List<Object?>> _anchorPaths = {}; |
| |
| /// Stores the true [SourceSpan] of alias reference tokens (`*name`) for |
| /// each entry `(parentCollection, keyOrIndex)`. |
| Map<_AliasEntryKey, SourceSpan> _aliasReferenceSpans = {}; |
| |
| /// Returns the current YAML string. |
| @override |
| String toString() => _yaml; |
| |
| factory YamlEditor(String yaml, |
| {AliasBehavior aliasBehavior = AliasBehavior.disallow}) => |
| YamlEditor._(yaml, aliasBehavior); |
| |
| YamlEditor._(this._yaml, this.aliasBehavior) |
| : _contents = loadYamlNode(_yaml, retainLayout: true) { |
| _initialize(); |
| } |
| |
| /// Traverses the YAML tree formed to detect alias nodes. |
| void _initialize() { |
| _aliases = Set.identity(); |
| _anchorPaths = Map.identity(); |
| _aliasReferenceSpans = {}; |
| |
| /// Performs a DFS on [_contents] to detect alias nodes. |
| // package:yaml shares the identical YamlNode instance for an anchor |
| // definition and all its alias references. Identity tracking is required |
| // because YamlNode implements structural equality, which would conflate |
| // distinct nodes having identical contents with shared alias references. |
| // The first encounter in DFS is recorded as the anchor definition. |
| final firstVisited = Map<YamlNode, List<Object?>>.identity(); |
| void collectAliases(YamlNode node, List<Object?> path) { |
| if (!firstVisited.containsKey(node)) { |
| firstVisited[node] = path; |
| if (node is YamlMap) { |
| node.nodes.forEach((key, value) { |
| collectAliases(key as YamlNode, [...path, key]); |
| collectAliases(value, [...path, key]); |
| }); |
| } else if (node is YamlList) { |
| for (var i = 0; i < node.length; i++) { |
| collectAliases(node.nodes[i], [...path, i]); |
| } |
| } |
| } else { |
| _aliases.add(node); |
| _anchorPaths[node] ??= firstVisited[node]!; |
| if (path.isNotEmpty) { |
| final parentCollection = |
| _traverse(path.take(path.length - 1), checkAlias: false); |
| final keyOrIndex = path.last; |
| _aliasReferenceSpans[_AliasEntryKey(parentCollection, keyOrIndex)] = |
| _computeAliasSpan(_yaml, parentCollection, keyOrIndex); |
| } |
| } |
| } |
| |
| collectAliases(_contents, []); |
| } |
| |
| SourceSpan _computeAliasSpan( |
| String yaml, YamlNode parentCollection, Object? keyOrIndex) { |
| int searchStart; |
| if (parentCollection is YamlMap) { |
| final colon = parentCollection.colonSpan(keyOrIndex); |
| if (colon != null) { |
| searchStart = colon.end.offset; |
| } else { |
| final keyNode = getKeyNode(parentCollection, keyOrIndex); |
| searchStart = keyNode.span.end.offset; |
| } |
| } else if (parentCollection is YamlList) { |
| final idx = keyOrIndex as int; |
| final dash = parentCollection.dashSpan(idx); |
| if (dash != null) { |
| searchStart = dash.end.offset; |
| } else if (idx > 0) { |
| searchStart = getTrueContentSensitiveEnd(parentCollection, idx - 1); |
| } else { |
| searchStart = parentCollection.span.start.offset; |
| } |
| } else { |
| searchStart = parentCollection.span.start.offset; |
| } |
| |
| // Scan forward through the YAML source to locate the alias reference |
| // token (`*anchor`) and its span, skipping comments. The AST node for an |
| // alias shares the span of the original anchor definition, so we must |
| // scan the source text to find the actual span of the alias reference. |
| var i = searchStart; |
| while (i < yaml.length) { |
| final ch = yaml.codeUnitAt(i); |
| if (ch == YamlChar.hash) { |
| while (i < yaml.length && !YamlChar.isLineBreak(yaml.codeUnitAt(i))) { |
| i++; |
| } |
| } else if (ch == YamlChar.asterisk) { |
| final starIndex = i; |
| i++; |
| while (i < yaml.length && YamlChar.isAnchorChar(yaml.codeUnitAt(i))) { |
| i++; |
| } |
| return SourceSpanBase(SourceLocation(starIndex), SourceLocation(i), |
| yaml.substring(starIndex, i)); |
| } else { |
| i++; |
| } |
| } |
| |
| final node = parentCollection is YamlMap |
| ? parentCollection |
| .nodes[keyOrIndex is YamlNode ? keyOrIndex.value : keyOrIndex]! |
| : (parentCollection as YamlList).nodes[keyOrIndex as int]; |
| return node.span; |
| } |
| |
| /// Returns the true source span of the node at [keyOrIndex] inside |
| /// [parentCollection]. For alias references (`*name`), returns the span |
| /// of the alias token itself rather than the anchor definition. |
| SourceSpan getTrueSpan(YamlNode parentCollection, Object? keyOrIndex, |
| [YamlNode? valueNode]) { |
| final aliasSpan = |
| _aliasReferenceSpans[_AliasEntryKey(parentCollection, keyOrIndex)]; |
| if (aliasSpan != null) return aliasSpan; |
| final node = valueNode ?? |
| (parentCollection is YamlMap |
| ? parentCollection.nodes[keyOrIndex] ?? |
| getYamlMapEntry(parentCollection, keyOrIndex).valueNode |
| : (parentCollection as YamlList).nodes[keyOrIndex as int]); |
| return node.span; |
| } |
| |
| /// Returns the true content-sensitive ending offset of the node at |
| /// [keyOrIndex] inside [parentCollection]. |
| int getTrueContentSensitiveEnd(YamlNode parentCollection, Object? keyOrIndex, |
| [YamlNode? valueNode]) { |
| final aliasSpan = |
| _aliasReferenceSpans[_AliasEntryKey(parentCollection, keyOrIndex)]; |
| if (aliasSpan != null) return aliasSpan.end.offset; |
| final node = valueNode ?? |
| (parentCollection is YamlMap |
| ? parentCollection.nodes[keyOrIndex] ?? |
| getYamlMapEntry(parentCollection, keyOrIndex).valueNode |
| : (parentCollection as YamlList).nodes[keyOrIndex as int]); |
| |
| return _getContentSensitiveEndWithAliases(node); |
| } |
| |
| int _getContentSensitiveEndWithAliases(YamlNode yamlNode) { |
| if (yamlNode is YamlList) { |
| if (yamlNode.style == CollectionStyle.FLOW || yamlNode.isEmpty) { |
| return yamlNode.span.end.offset; |
| } else { |
| final lastIdx = yamlNode.length - 1; |
| final lastItem = yamlNode.nodes[lastIdx]; |
| final aliasSpan = |
| _aliasReferenceSpans[_AliasEntryKey(yamlNode, lastIdx)]; |
| if (aliasSpan != null) { |
| return aliasSpan.end.offset; |
| } |
| return _getContentSensitiveEndWithAliases(lastItem); |
| } |
| } else if (yamlNode is YamlMap) { |
| if (yamlNode.style == CollectionStyle.FLOW || yamlNode.isEmpty) { |
| return yamlNode.span.end.offset; |
| } else { |
| final lastKey = yamlNode.nodes.keys.last; |
| final lastValue = yamlNode.nodes[lastKey]!; |
| final aliasSpan = |
| _aliasReferenceSpans[_AliasEntryKey(yamlNode, lastKey)]; |
| if (aliasSpan != null) { |
| return aliasSpan.end.offset; |
| } |
| return _getContentSensitiveEndWithAliases(lastValue); |
| } |
| } |
| |
| return yamlNode.span.end.offset; |
| } |
| |
| /// Checks whether the child at [keyOrIndex] in [parentCollection] is an |
| /// anchor definition node (`&anchor`). |
| bool isAnchorDefinition(YamlNode parentCollection, Object? keyOrIndex) { |
| final node = parentCollection is YamlMap |
| ? parentCollection |
| .nodes[keyOrIndex is YamlNode ? keyOrIndex.value : keyOrIndex]! |
| : (parentCollection as YamlList).nodes[keyOrIndex as int]; |
| if (!_aliases.contains(node)) return false; |
| final anchorPath = _anchorPaths[node]; |
| if (anchorPath == null) return false; |
| return _aliasReferenceSpans[_AliasEntryKey(parentCollection, keyOrIndex)] == |
| null; |
| } |
| |
| bool _isAliasReferenceNode(YamlNode node, Iterable<Object?> path) { |
| if (!_aliases.contains(node)) return false; |
| final anchorPath = _anchorPaths[node]; |
| if (anchorPath == null) return false; |
| return !_pathsEqual(anchorPath, path); |
| } |
| |
| bool _pathsEqual(Iterable<Object?> a, Iterable<Object?> b) { |
| if (a.length != b.length) return false; |
| final itA = a.iterator; |
| final itB = b.iterator; |
| while (itA.moveNext() && itB.moveNext()) { |
| if (!deepEquals(itA.current, itB.current)) return false; |
| } |
| return true; |
| } |
| |
| String _getTargetValueRepresentation( |
| YamlNode parentCollection, |
| Object? keyOrIndex, |
| YamlNode targetNode, |
| String anchorTag, |
| ) { |
| final span = getTrueSpan(parentCollection, keyOrIndex, targetNode); |
| var text = span.text; |
| if (text.startsWith('$anchorTag ')) { |
| text = text.substring(anchorTag.length + 1); |
| } else if (text.startsWith(anchorTag)) { |
| text = text.substring(anchorTag.length); |
| } |
| |
| if ((targetNode is YamlMap || targetNode is YamlList) && |
| !isFlowYamlCollectionNode(targetNode)) { |
| if (!text.startsWith(RegExp(r'\r?\n'))) { |
| text = '\n$text'; |
| } |
| return text.trimRight(); |
| } |
| |
| return text.trim(); |
| } |
| |
| /// Collects sub-anchors defined within [anchorNode] and their string |
| /// representations so intra-template alias references can be expanded inline |
| /// and duplicate sub-anchor definitions can be stripped during shallow |
| /// unfolding. References to external anchors are left intact. |
| _IntraTemplateInfo _collectIntraTemplateAnchors( |
| YamlNode anchorNode, |
| List<Object?> anchorPath, |
| ) { |
| final anchorTags = <String>{}; |
| final anchorValues = <String, String>{}; |
| |
| void walk(YamlNode current, List<Object?> currentPath) { |
| if (current is YamlMap) { |
| for (final entry in current.nodes.entries) { |
| final key = entry.key; |
| final value = entry.value; |
| final unwrappedKey = key is YamlNode ? key.value : key; |
| final childPath = [...currentPath, unwrappedKey]; |
| |
| if (_isAliasReferenceNode(value, childPath)) { |
| // Alias references do not define anchors here. |
| continue; |
| } |
| |
| final tag = getAnchorTag(current, key); |
| if (tag != null) { |
| anchorTags.add(tag); |
| anchorValues[tag] = |
| _getTargetValueRepresentation(current, key, value, tag); |
| } |
| |
| if (value is YamlMap || value is YamlList) { |
| walk(value, childPath); |
| } |
| } |
| } else if (current is YamlList) { |
| for (var i = 0; i < current.nodes.length; i++) { |
| final item = current.nodes[i]; |
| final childPath = [...currentPath, i]; |
| |
| if (_isAliasReferenceNode(item, childPath)) { |
| // Alias references do not define anchors here. |
| continue; |
| } |
| |
| final tag = getAnchorTag(current, i); |
| if (tag != null) { |
| anchorTags.add(tag); |
| anchorValues[tag] = |
| _getTargetValueRepresentation(current, i, item, tag); |
| } |
| |
| if (item is YamlMap || item is YamlList) { |
| walk(item, childPath); |
| } |
| } |
| } |
| } |
| |
| walk(anchorNode, anchorPath); |
| |
| // Strip nested sub-anchor tags from anchor values. |
| for (final tag in anchorValues.keys) { |
| for (final subTag in anchorTags) { |
| anchorValues[tag] = anchorValues[tag]!.replaceAll('$subTag ', ''); |
| anchorValues[tag] = anchorValues[tag]!.replaceAll( |
| RegExp('${RegExp.escape(subTag)}\\r?\\n'), |
| '\n', |
| ); |
| } |
| } |
| |
| // Resolve any nested intra-template aliases within anchor values. Sort |
| // keys by descending length so longer anchor names are replaced before |
| // shorter prefixes. |
| final sortedKeys = anchorValues.keys.toList() |
| ..sort((a, b) => b.length.compareTo(a.length)); |
| for (final tag in anchorValues.keys) { |
| for (final otherTag in sortedKeys) { |
| final name = otherTag.substring(1); |
| final val = anchorValues[otherTag]!; |
| if (val.startsWith(RegExp(r'\r?\n'))) { |
| anchorValues[tag] = anchorValues[tag]!.replaceAll( |
| aliasReferencePattern(name, includeLeadingWhitespace: true), |
| val, |
| ); |
| } else { |
| anchorValues[tag] = anchorValues[tag]!.replaceAll( |
| aliasReferencePattern(name), |
| val, |
| ); |
| } |
| } |
| } |
| |
| return _IntraTemplateInfo(anchorTags, anchorValues); |
| } |
| |
| String _getUnfoldedText( |
| YamlNode parentOfAnchor, |
| Object? keyOfAnchor, |
| YamlNode anchorNode, |
| int targetIndentation, |
| String lineEnding, { |
| List<Object?>? anchorPath, |
| }) { |
| final span = getTrueSpan(parentOfAnchor, keyOfAnchor, anchorNode); |
| var text = span.text; |
| |
| // Strip top-level anchor definition tag (`&anchorName\n` or `&anchorName `). |
| if (text.startsWith('&')) { |
| final wsIdx = text.indexOf(RegExp(r'\s')); |
| if (wsIdx != -1) { |
| text = text.substring(wsIdx); |
| } else { |
| text = ''; |
| } |
| } |
| |
| final resolvedAnchorPath = anchorPath ?? _anchorPaths[anchorNode] ?? []; |
| final intraInfo = |
| _collectIntraTemplateAnchors(anchorNode, resolvedAnchorPath); |
| |
| // Strip nested intra-template sub-anchor definition tags to prevent |
| // duplicate anchor definitions. |
| for (final tag in intraInfo.anchorTags) { |
| text = text.replaceAll('$tag ', ''); |
| text = text.replaceAll(RegExp('${RegExp.escape(tag)}\\r?\\n'), '\n'); |
| } |
| |
| // Expand intra-template alias references inline so the decoupled copy is |
| // completely self-contained. Sort by descending tag length so longer |
| // anchor names are replaced before shorter prefixes (e.g. `*ref_long` |
| // before `*ref`). |
| final sortedTags = intraInfo.anchorValues.keys.toList() |
| ..sort((a, b) => b.length.compareTo(a.length)); |
| for (final tag in sortedTags) { |
| final targetValue = intraInfo.anchorValues[tag]!; |
| final anchorName = tag.substring(1); |
| if (targetValue.startsWith(RegExp(r'\r?\n'))) { |
| final aliasRegex = |
| aliasReferencePattern(anchorName, includeLeadingWhitespace: true); |
| text = text.replaceAll(aliasRegex, targetValue); |
| } else { |
| final aliasRegex = aliasReferencePattern(anchorName); |
| text = text.replaceAll(aliasRegex, targetValue); |
| } |
| } |
| |
| if (anchorNode is YamlMap || anchorNode is YamlList) { |
| if (isFlowYamlCollectionNode(anchorNode)) { |
| return text.trim(); |
| } |
| |
| // Block collection: split into lines and re-indent |
| final lines = text.trimRight().split(RegExp(r'\r?\n')); |
| int? baseIndent; |
| for (final line in lines) { |
| if (line.trim().isNotEmpty) { |
| final leadingSpaces = line.length - line.trimLeft().length; |
| if (baseIndent == null || leadingSpaces < baseIndent) { |
| baseIndent = leadingSpaces; |
| } |
| } |
| } |
| |
| baseIndent ??= 0; |
| final reindentedLines = <String>[]; |
| for (var i = 0; i < lines.length; i++) { |
| final line = lines[i]; |
| if (line.trim().isEmpty) { |
| if (i > 0 && i < lines.length - 1) reindentedLines.add(''); |
| continue; |
| } |
| final leadingSpaces = line.length - line.trimLeft().length; |
| final relIndent = leadingSpaces - baseIndent; |
| final newIndent = targetIndentation + relIndent; |
| reindentedLines |
| .add((' ' * (newIndent > 0 ? newIndent : 0)) + line.trimLeft()); |
| } |
| |
| var result = reindentedLines.join(lineEnding); |
| if (text.startsWith(RegExp(r'\r?\n'))) { |
| result = lineEnding + result; |
| } |
| return result; |
| } |
| |
| return text.trim(); |
| } |
| |
| void _materializeAliasReference(Iterable<Object?> aliasPath) { |
| final pathList = aliasPath.toList(); |
| final parentPath = pathList.take(pathList.length - 1); |
| final keyOrIndex = pathList.last; |
| final parentCollection = _traverse(parentPath, checkAlias: false); |
| final node = _traverse(aliasPath, checkAlias: false); |
| final anchorPath = _anchorPaths[node]!; |
| |
| final anchorParentPath = anchorPath.take(anchorPath.length - 1); |
| final anchorKeyOrIndex = anchorPath.last; |
| final anchorParent = _traverse(anchorParentPath, checkAlias: false); |
| final anchorNode = _traverse(anchorPath, checkAlias: false); |
| |
| final lineEnding = getLineEnding(_yaml); |
| var targetIndentation = 0; |
| if (parentCollection is YamlMap) { |
| targetIndentation = |
| getMapIndentation(_yaml, parentCollection) + getIndentation(this); |
| } else if (parentCollection is YamlList) { |
| targetIndentation = |
| getListIndentation(_yaml, parentCollection) + getIndentation(this); |
| } |
| |
| final unfoldedText = _getUnfoldedText( |
| anchorParent, |
| anchorKeyOrIndex, |
| anchorNode, |
| targetIndentation, |
| lineEnding, |
| anchorPath: anchorPath, |
| ); |
| |
| final aliasSpan = getTrueSpan(parentCollection, keyOrIndex, node); |
| var start = aliasSpan.start.offset; |
| var length = aliasSpan.length; |
| var replacement = unfoldedText; |
| |
| // If the unfolded block replacement begins with a newline, consume the |
| // preceding space (e.g. in `key: *alias`) to avoid leaving trailing |
| // whitespace after the colon or hyphen. |
| if (unfoldedText.startsWith(lineEnding) && |
| start > 0 && |
| _yaml[start - 1] == ' ') { |
| start -= 1; |
| length += 1; |
| } |
| |
| final edit = SourceEdit(start, length, replacement); |
| _performEdit(edit, aliasPath, anchorNode); |
| } |
| |
| /// Resolves any alias references encountered along [path] according to |
| /// [aliasBehavior]. |
| /// |
| /// When [resolveLeaf] is `true`, the node at the end of [path] is also |
| /// resolved (used by list mutations where the path targets the collection |
| /// itself). Otherwise, only intermediate path segments are resolved because |
| /// an update replaces the leaf node directly. |
| Iterable<Object?> _resolvePath(Iterable<Object?> path, |
| {bool resolveLeaf = false}) { |
| final pathList = path.toList(); |
| if (pathList.isEmpty) return pathList; |
| |
| final maxIdx = resolveLeaf ? pathList.length : pathList.length - 1; |
| for (var i = 0; i < maxIdx; i++) { |
| final subPath = pathList.take(i + 1).toList(); |
| final node = _traverse(subPath, checkAlias: false); |
| if (_isAliasReferenceNode(node, subPath)) { |
| if (aliasBehavior == AliasBehavior.disallow) { |
| throw AliasException(path, node); |
| } else if (aliasBehavior == AliasBehavior.reference) { |
| final anchorPath = _anchorPaths[node]!; |
| final redirectedPath = [...anchorPath, ...pathList.skip(i + 1)]; |
| return _resolvePath(redirectedPath, resolveLeaf: resolveLeaf); |
| } else if (aliasBehavior == AliasBehavior.copyOnWrite) { |
| _materializeAliasReference(subPath); |
| return _resolvePath(path, resolveLeaf: resolveLeaf); |
| } |
| } |
| } |
| return pathList; |
| } |
| |
| /// 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<Object?> 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. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **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) { |
| path = _resolvePath(path); |
| final valueNode = wrapAsYamlNode(value); |
| |
| if (path.isEmpty) { |
| final start = _contents.span.start.offset; |
| final lineEnding = getLineEnding(_yaml); |
| final rawReplacement = yamlEncodeBlock(valueNode, 0, lineEnding); |
| final (:replacement, :endOffset) = preserveTrailingCommentOnBlockScalar( |
| oldNode: _contents, |
| yaml: _yaml, |
| replacement: rawReplacement, |
| currentEndOffset: _getContentSensitiveEndWithAliases(_contents), |
| lineEnding: lineEnding, |
| ); |
| final edit = SourceEdit(start, endOffset - start, replacement); |
| |
| 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: aliasBehavior == AliasBehavior.disallow, |
| ); |
| |
| if (aliasBehavior == AliasBehavior.disallow && |
| _aliases.contains(parentNode)) { |
| throw AliasException(path, parentNode); |
| } |
| |
| if (parentNode is YamlList) { |
| if (keyOrIndex is! int) { |
| throw PathError(path, path, parentNode); |
| } |
| if (isValidIndex(keyOrIndex, parentNode.length) && |
| aliasBehavior == AliasBehavior.disallow && |
| _aliases.contains(parentNode.nodes[keyOrIndex])) { |
| throw AliasException(path, parentNode.nodes[keyOrIndex]); |
| } |
| final expectedList = [...parentNode.nodes]..[keyOrIndex] = valueNode; |
| if (aliasBehavior != AliasBehavior.disallow) { |
| // If an anchor definition was updated, update sibling alias references |
| // pointing to it so the expected AST matches the re-parsed document. |
| for (var i = 0; i < expectedList.length; i++) { |
| if (i != keyOrIndex && |
| _isAliasReferenceNode( |
| parentNode.nodes[i], [...collectionPath, i])) { |
| final anchorPath = _anchorPaths[parentNode.nodes[i]]!; |
| if (_pathsEqual(anchorPath, [...collectionPath, keyOrIndex])) { |
| expectedList[i] = valueNode; |
| } |
| } |
| } |
| } |
| final expected = wrapAsYamlNode(expectedList); |
| if (aliasBehavior != AliasBehavior.disallow && expected is YamlListWrap) { |
| // Restore self-referential cyclic alias references pointing to the |
| // collection itself. |
| for (var i = 0; i < expectedList.length; i++) { |
| if (_isAliasReferenceNode( |
| parentNode.nodes[i], [...collectionPath, i])) { |
| final anchorPath = _anchorPaths[parentNode.nodes[i]]!; |
| if (_pathsEqual(anchorPath, collectionPath)) { |
| expected.nodes[i] = expected; |
| } |
| } |
| } |
| } |
| |
| return _performEdit(updateInList(this, parentNode, keyOrIndex, valueNode), |
| collectionPath, expected); |
| } |
| |
| if (parentNode is YamlMap) { |
| if (aliasBehavior == AliasBehavior.disallow && |
| containsKey(parentNode, keyOrIndex) && |
| _aliases.contains(parentNode.nodes[keyOrIndex])) { |
| throw AliasException(path, parentNode.nodes[keyOrIndex]!); |
| } |
| final expectedMap = updatedYamlMap(parentNode, (nodes) { |
| nodes[keyOrIndex] = valueNode; |
| if (aliasBehavior != AliasBehavior.disallow) { |
| // If an anchor definition was updated, update sibling alias |
| // references pointing to it so the expected AST matches the re-parsed |
| // document. |
| for (final k in nodes.keys.toList()) { |
| if (!deepEquals(k, keyOrIndex) && |
| nodes[k] is YamlNode && |
| _isAliasReferenceNode( |
| nodes[k] as YamlNode, [...collectionPath, k])) { |
| final anchorPath = _anchorPaths[nodes[k]]!; |
| if (_pathsEqual(anchorPath, [...collectionPath, keyOrIndex])) { |
| nodes[k] = valueNode; |
| } |
| } |
| } |
| } |
| }); |
| |
| if (aliasBehavior != AliasBehavior.disallow && |
| expectedMap is YamlMapWrap) { |
| // Restore self-referential cyclic alias references pointing to the |
| // collection itself. |
| for (final entry in parentNode.nodes.entries) { |
| final k = entry.key; |
| final node = entry.value; |
| if (_isAliasReferenceNode(node, [...collectionPath, k])) { |
| final anchorPath = _anchorPaths[node]!; |
| if (_pathsEqual(anchorPath, collectionPath)) { |
| final keyNode = getKeyNode(expectedMap, k); |
| expectedMap.nodes[keyNode] = expectedMap; |
| } |
| } |
| } |
| } |
| |
| 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. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **Example:** |
| /// ```dart |
| /// final doc = YamlEditor('[0, 1]'); |
| /// doc.appendToList([], 2); // [0, 1, 2] |
| /// ``` |
| void appendToList(Iterable<Object?> path, Object? value) { |
| path = _resolvePath(path, resolveLeaf: true); |
| final yamlList = _traverseToList( |
| path, |
| checkAlias: aliasBehavior == AliasBehavior.disallow, |
| ); |
| |
| 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. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **Example:** |
| /// ```dart |
| /// final doc = YamlEditor('[1, 2]'); |
| /// doc.prependToList([], 0); // [0, 1, 2] |
| /// ``` |
| void prependToList(Iterable<Object?> path, Object? value) { |
| path = _resolvePath(path, resolveLeaf: true); |
| 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. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **Example:** |
| /// ```dart |
| /// final doc = YamlEditor('[0, 2]'); |
| /// doc.insertIntoList([], 1, 1); // [0, 1, 2] |
| /// ``` |
| void insertIntoList(Iterable<Object?> path, int index, Object? value) { |
| path = _resolvePath(path, resolveLeaf: true); |
| final valueNode = wrapAsYamlNode(value); |
| |
| final list = _traverseToList( |
| path, |
| checkAlias: aliasBehavior == AliasBehavior.disallow, |
| ); |
| 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. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **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) { |
| path = _resolvePath(path, resolveLeaf: true); |
| final list = _traverseToList( |
| path, |
| checkAlias: aliasBehavior == AliasBehavior.disallow, |
| ); |
| |
| 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]. |
| /// |
| /// Throws a [PathError] if the path is invalid. |
| /// |
| /// When [aliasBehavior] is [AliasBehavior.disallow], throws an |
| /// [AliasException] if a node along [path] is an alias reference or anchor |
| /// definition. For other [AliasBehavior] modes, see [AliasBehavior] for |
| /// redirection and Copy-On-Write semantics. |
| /// |
| /// **Example:** |
| /// ```dart |
| /// final doc = YamlEditor('[0, 1]'); |
| /// doc.remove([1]); // [0] |
| /// ``` |
| YamlNode remove(Iterable<Object?> path) { |
| path = _resolvePath(path); |
| SourceEdit edit; |
| YamlNode expectedNode; |
| final nodeToRemove = _traverse( |
| path, |
| checkAlias: aliasBehavior == AliasBehavior.disallow, |
| ); |
| |
| // Disallow removing an anchor definition if alias references still point |
| // to it, which would leave dangling references and corrupt the document. |
| if (aliasBehavior != AliasBehavior.disallow && |
| _aliases.contains(nodeToRemove) && |
| _anchorPaths[nodeToRemove] != null && |
| _pathsEqual(_anchorPaths[nodeToRemove]!, path) && |
| _hasActiveReferencesToAnchor(nodeToRemove)) { |
| throw AliasException(path, nodeToRemove); |
| } |
| |
| if (path.isEmpty) { |
| edit = SourceEdit(0, _yaml.length, ''); |
| _performEdit(edit, path, wrapAsYamlNode(null)); |
| return nodeToRemove; |
| } |
| |
| final pathList = path.toList(); |
| final collectionPath = pathList.take(path.length - 1); |
| final keyOrIndex = pathList.last; |
| final parentNode = _traverse(collectionPath, |
| checkAlias: aliasBehavior == AliasBehavior.disallow); |
| |
| if (aliasBehavior == AliasBehavior.disallow && |
| _aliases.contains(parentNode)) { |
| throw AliasException(path, parentNode); |
| } |
| |
| 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)); |
| } else { |
| throw PathError.unexpected( |
| path, 'Scalar $parentNode does not have key $keyOrIndex'); |
| } |
| |
| _performEdit(edit, collectionPath, expectedNode); |
| |
| return nodeToRemove; |
| } |
| |
| /// Traverses down [path] to return the [YamlNode] at [path] if successful. |
| YamlNode _traverse(Iterable<Object?> 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<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]) { |
| 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<Object?> 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<Object?> path, |
| YamlNode expectedNode, |
| ) { |
| final expectedTree = _deepModify(_contents, path, [], expectedNode); |
| final initialYaml = _yaml; |
| final updatedYaml = edit.apply(_yaml); |
| |
| // Check that the edit does actually parse |
| final YamlNode actualTree; |
| try { |
| actualTree = withYamlWarningCallback( |
| () => loadYamlNode(updatedYaml, retainLayout: true)); |
| } on YamlException { |
| throw createAssertionError( |
| 'Failed to produce valid YAML after modification.', |
| initialYaml, |
| updatedYaml, |
| ); |
| } |
| |
| // Check that the edit is semantically correct |
| if (!deepEquals(actualTree, expectedTree)) { |
| throw createAssertionError( |
| 'Modification did not result in expected result.', |
| initialYaml, |
| updatedYaml, |
| ); |
| } |
| |
| // Update state of YamlEditor, when we've validated that the edit was |
| // semantically correct! |
| _yaml = updatedYaml; |
| _contents = actualTree; |
| _edits.add(edit); |
| _initialize(); // update tracking of aliases |
| } |
| |
| /// Returns whether there are any active alias references in the document |
| /// that point to [anchorNode]. |
| bool _hasActiveReferencesToAnchor(YamlNode anchorNode) { |
| final anchorPath = _anchorPaths[anchorNode]; |
| if (anchorPath == null) return false; |
| |
| final visited = Set<YamlNode>.identity(); |
| var hasReference = false; |
| void checkNode(YamlNode node, List<Object?> currentPath) { |
| if (hasReference) return; |
| if (_aliases.contains(node) && |
| !_pathsEqual(currentPath, anchorPath) && |
| _pathsEqual(_anchorPaths[node]!, anchorPath)) { |
| hasReference = true; |
| return; |
| } |
| if (!visited.add(node)) return; |
| |
| if (node is YamlMap) { |
| node.nodes.forEach((k, v) { |
| checkNode(k as YamlNode, [...currentPath, k]); |
| checkNode(v, [...currentPath, k]); |
| }); |
| } else if (node is YamlList) { |
| for (var i = 0; i < node.length; i++) { |
| checkNode(node.nodes[i], [...currentPath, i]); |
| } |
| } |
| } |
| |
| checkNode(_contents, []); |
| return hasReference; |
| } |
| |
| /// Returns the clean anchor tag (`&anchorName`) associated with the node |
| /// at [keyOrIndex] inside [parentCollection], or `null` if none exists. |
| String? getAnchorTag(YamlNode parentCollection, Object? keyOrIndex) { |
| final unwrappedKey = keyOrIndex is YamlNode ? keyOrIndex.value : keyOrIndex; |
| if (_aliasReferenceSpans[_AliasEntryKey(parentCollection, unwrappedKey)] != |
| null) { |
| return null; |
| } |
| final span = getTrueSpan(parentCollection, unwrappedKey); |
| final text = span.text; |
| if (text.startsWith('&')) { |
| final idx = text.indexOf(RegExp(r'\s')); |
| if (idx != -1) { |
| return text.substring(0, idx); |
| } |
| return text; |
| } |
| return null; |
| } |
| |
| /// 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) { |
| return _updateNodeAndAliases( |
| tree, path.toList(), subPath.toList(), expectedNode); |
| } |
| |
| /// Produces the expected AST by replacing the node at [targetPath] with |
| /// [expectedNode] and propagating the change to all alias references in the |
| /// document whose anchor definition was modified. |
| /// |
| /// Uses an identity-based [visited] set to guard against infinite loops on |
| /// cyclic YAML structures. |
| YamlNode _updateNodeAndAliases( |
| YamlNode tree, |
| List<Object?> targetPath, |
| List<Object?> currentPath, |
| YamlNode expectedNode, [ |
| Set<YamlNode>? visited, |
| ]) { |
| final isOnTargetPath = currentPath.length <= targetPath.length && |
| _pathsEqual(currentPath, targetPath.take(currentPath.length)); |
| |
| if (isOnTargetPath && currentPath.length == targetPath.length) { |
| return expectedNode; |
| } |
| |
| final activeVisited = visited ?? Set<YamlNode>.identity(); |
| if (!activeVisited.add(tree)) { |
| return tree; |
| } |
| |
| try { |
| final keyOrIndex = isOnTargetPath ? targetPath[currentPath.length] : null; |
| |
| if (tree is YamlList) { |
| if (isOnTargetPath && !isValidIndex(keyOrIndex, tree.length)) { |
| throw PathError(targetPath, currentPath, tree); |
| } |
| |
| final newNodes = <YamlNode>[]; |
| for (var i = 0; i < tree.length; i++) { |
| final item = tree.nodes[i]; |
| if (isOnTargetPath && i == keyOrIndex) { |
| newNodes.add(_updateNodeAndAliases( |
| item, |
| targetPath, |
| [...currentPath, i], |
| expectedNode, |
| activeVisited, |
| )); |
| } else if (aliasBehavior != AliasBehavior.disallow && |
| _isAliasReferenceNode(item, [...currentPath, i])) { |
| final anchorPath = _anchorPaths[item]!; |
| if (targetPath.length >= anchorPath.length && |
| _pathsEqual(targetPath.take(anchorPath.length), anchorPath)) { |
| newNodes.add(_updateNodeAndAliases( |
| item, |
| targetPath, |
| anchorPath, |
| expectedNode, |
| activeVisited, |
| )); |
| } else { |
| newNodes.add(item); |
| } |
| } else if (aliasBehavior != AliasBehavior.disallow && |
| (item is YamlMap || item is YamlList)) { |
| newNodes.add(_updateNodeAndAliases( |
| item, |
| targetPath, |
| [...currentPath, i], |
| expectedNode, |
| activeVisited, |
| )); |
| } else { |
| newNodes.add(item); |
| } |
| } |
| return wrapAsYamlNode(newNodes); |
| } |
| |
| if (tree is YamlMap) { |
| if (isOnTargetPath && !containsKey(tree, keyOrIndex)) { |
| throw PathError(targetPath, currentPath, tree); |
| } |
| final newMap = <Object?, Object?>{}; |
| for (final entry in tree.nodes.entries) { |
| final key = entry.key; |
| final item = entry.value; |
| if (isOnTargetPath && deepEquals(key, keyOrIndex)) { |
| newMap[key] = _updateNodeAndAliases( |
| item, |
| targetPath, |
| [...currentPath, key], |
| expectedNode, |
| activeVisited, |
| ); |
| } else if (aliasBehavior != AliasBehavior.disallow && |
| _isAliasReferenceNode(item, [...currentPath, key])) { |
| final anchorPath = _anchorPaths[item]!; |
| if (targetPath.length >= anchorPath.length && |
| _pathsEqual(targetPath.take(anchorPath.length), anchorPath)) { |
| newMap[key] = _updateNodeAndAliases( |
| item, |
| targetPath, |
| anchorPath, |
| expectedNode, |
| activeVisited, |
| ); |
| } else { |
| newMap[key] = item; |
| } |
| } else if (aliasBehavior != AliasBehavior.disallow && |
| (item is YamlMap || item is YamlList)) { |
| newMap[key] = _updateNodeAndAliases( |
| item, |
| targetPath, |
| [...currentPath, key], |
| expectedNode, |
| activeVisited, |
| ); |
| } else { |
| newMap[key] = item; |
| } |
| } |
| return wrapAsYamlNode(newMap); |
| } |
| |
| if (isOnTargetPath) { |
| throw PathError(targetPath, currentPath, tree); |
| } |
| return tree; |
| } finally { |
| activeVisited.remove(tree); |
| } |
| } |
| } |
| |
| final class _AliasEntryKey { |
| final YamlNode parent; |
| final Object? keyOrIndex; |
| |
| _AliasEntryKey(this.parent, Object? keyOrIndex) |
| : keyOrIndex = keyOrIndex is YamlNode ? keyOrIndex.value : keyOrIndex; |
| |
| @override |
| bool operator ==(Object other) => |
| other is _AliasEntryKey && |
| parent == other.parent && |
| deepEquals(keyOrIndex, other.keyOrIndex); |
| |
| @override |
| int get hashCode => parent.hashCode ^ keyOrIndex.hashCode; |
| } |
| |
| final class _IntraTemplateInfo { |
| final Set<String> anchorTags; |
| final Map<String, String> anchorValues; |
| |
| _IntraTemplateInfo(this.anchorTags, this.anchorValues); |
| } |
| |
| /// Returns a [RegExp] matching alias references to [anchorName] |
| /// (`*anchorName`). |
| /// |
| /// Uses a positive lookahead `(?=[\s,\[\]{}]|$)` to match YAML delimiter |
| /// boundaries, ensuring anchor names containing special characters (e.g. `.`, |
| /// `/`, `@`, `+`) match accurately and do not match substrings of longer |
| /// anchor names. |
| /// |
| /// If [includeLeadingWhitespace] is `true`, leading spaces and tabs before the |
| /// `*` are also matched. |
| /// |
| /// Runs in O(1) time complexity to compile the pattern. |
| RegExp aliasReferencePattern(String anchorName, |
| {bool includeLeadingWhitespace = false}) { |
| final prefix = includeLeadingWhitespace ? r'[ \t]*\*' : r'\*'; |
| return RegExp('$prefix${RegExp.escape(anchorName)}(?=[\\s,\\[\\]{}]|\$)'); |
| } |