From 0a42043fb6a5e0c04e641a48fd56cee19ba0a2b7 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:25:05 +0000 Subject: [PATCH 01/10] Prevent `YamlEditor.remove` from affecting existing block scalars * Refactor `_removeFromBlockList` and `_removeFromBlockMap` and remove dangling line breaks which may affect how block scalars are intepreted by the `package: yaml`. * Prefer obtaining an entry in one pass from `YamlMap`. * Add tests --- pkgs/yaml_edit/lib/src/equality.dart | 14 + pkgs/yaml_edit/lib/src/list_mutations.dart | 95 +++---- pkgs/yaml_edit/lib/src/map_mutations.dart | 101 +++---- pkgs/yaml_edit/lib/src/utils.dart | 309 +++++++++++++++++++++ pkgs/yaml_edit/test/remove_test.dart | 78 ++++++ 5 files changed, 470 insertions(+), 127 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/equality.dart b/pkgs/yaml_edit/lib/src/equality.dart index 0c6a9526a..cf6404481 100644 --- a/pkgs/yaml_edit/lib/src/equality.dart +++ b/pkgs/yaml_edit/lib/src/equality.dart @@ -91,6 +91,20 @@ YamlNode getKeyNode(YamlMap map, Object? key) { return map.nodes.keys.firstWhere((node) => deepEquals(node, key)) as YamlNode; } +/// Returns the entry associated with a [mapKey] and its index in the [map]. +({int index, YamlNode keyNode, YamlNode valueNode}) getYamlMapEntry( + YamlMap map, + Object? mapKey, +) { + for (final (index, MapEntry(:key, :value)) in map.nodes.entries.indexed) { + if (deepEquals(key, mapKey)) { + return (index: index, keyNode: key, valueNode: value); + } + } + + throw YamlException('$mapKey not found in map', map.span); +} + /// Returns the [YamlNode] after the [YamlNode] corresponding to the provided /// [key]. YamlNode? getNextKeyNode(YamlMap map, Object? key) { diff --git a/pkgs/yaml_edit/lib/src/list_mutations.dart b/pkgs/yaml_edit/lib/src/list_mutations.dart index 17da6dd77..acf1f36b9 100644 --- a/pkgs/yaml_edit/lib/src/list_mutations.dart +++ b/pkgs/yaml_edit/lib/src/list_mutations.dart @@ -306,72 +306,43 @@ SourceEdit _insertInFlowList( /// [index] should be non-negative and less than or equal to `list.length`. SourceEdit _removeFromBlockList( YamlEditor yamlEdit, YamlList list, YamlNode nodeToRemove, int index) { - 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 listSize = list.length; + RangeError.checkValueInInterval(index, 0, listSize - 1); 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, ''); + return removeBlockCollectionEntry( + yaml, + blockCollection: list, + isFirstEntry: index == 0, + isSingleEntry: listSize == 1, + isLastEntry: index >= listSize - 1, + nodeToRemoveOffset: ( + start: span.length == 0 + ? span.start.offset + : yaml.lastIndexOf('-', span.start.offset - 1), + end: getContentSensitiveEnd(nodeToRemove) + ), + lineEnding: getLineEnding(yaml), + nextBlockNodeInfo: () { + final nextNode = list.nodes[index + 1]; + final nextNodeSpan = nextNode.span; + final offset = nextNodeSpan.start.offset; + + final hyphenOffset = yaml.lastIndexOf( + '-', + span.length == 0 ? offset : offset - 1, + ); + + final nearestLineEnding = yaml.lastIndexOf('\n', hyphenOffset); + + return ( + nearestLineEnding: nearestLineEnding, + nextNodeColStart: hyphenOffset - (nearestLineEnding + 1), + ); + }, + ); } /// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to diff --git a/pkgs/yaml_edit/lib/src/map_mutations.dart b/pkgs/yaml_edit/lib/src/map_mutations.dart index 46e8c7935..3b655c95d 100644 --- a/pkgs/yaml_edit/lib/src/map_mutations.dart +++ b/pkgs/yaml_edit/lib/src/map_mutations.dart @@ -36,13 +36,11 @@ SourceEdit updateInMap( /// removing the element at [key] when re-parsed. SourceEdit removeInMap(YamlEditor yamlEdit, YamlMap map, Object? key) { assert(containsKey(map, key)); - final keyNode = getKeyNode(map, key); - final valueNode = map.nodes[keyNode]!; if (map.style == CollectionStyle.FLOW) { - return _removeFromFlowMap(yamlEdit, map, keyNode, valueNode); + return _removeFromFlowMap(yamlEdit, map, key); } else { - return _removeFromBlockMap(yamlEdit, map, keyNode, valueNode); + return _removeFromBlockMap(yamlEdit, map, key); } } @@ -169,74 +167,47 @@ SourceEdit _replaceInFlowMap( } /// Performs the string operation on [yamlEdit] to achieve the effect of -/// removing the [keyNode] from the map, bearing in mind that this is a block +/// 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) { - final keySpan = keyNode.span; - var end = getContentSensitiveEnd(valueNode); +SourceEdit _removeFromBlockMap(YamlEditor yamlEdit, YamlMap map, Object? key) { + final (index: entryIndex, :keyNode, :valueNode) = getYamlMapEntry(map, key); final yaml = yamlEdit.toString(); - final lineEnding = getLineEnding(yaml); - - if (map.length == 1) { - final start = map.span.start.offset; - final nextNewLine = yaml.indexOf(lineEnding, end); - if (nextNewLine != -1) { - // Remove everything up to the next newline, this strips comments that - // follows on the same line as the value we're removing. - // It also ensures we consume colon when [valueNode.value] is `null` - // because there is no value (e.g. `key: \n`). Because [valueNode.span] in - // such cases point to the colon `:`. - end = nextNewLine; - } else { - // Remove everything until the end of the document, if there is no newline - end = yaml.length; - } - 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(lineEnding, end); - if (nextNewLine != -1) { - end = nextNewLine + lineEnding.length; - } else { - // Remove everything until the end of the document, if there is no newline - end = yaml.length; - } - - final nextNode = getNextKeyNode(map, keyNode); - - if (start > 0) { - final lastHyphen = yaml.lastIndexOf('-', start - 1); - final lastNewLine = yaml.lastIndexOf(lineEnding, 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 + lineEnding.length; - } - } + final mapSize = map.length; + final keySpan = keyNode.span; - return SourceEdit(start, end - start, ''); + return removeBlockCollectionEntry( + yaml, + blockCollection: map, + isFirstEntry: entryIndex == 0, + isSingleEntry: mapSize == 1, + isLastEntry: entryIndex >= mapSize - 1, + nodeToRemoveOffset: ( + start: keySpan.start.offset, + end: valueNode.span.length == 0 + ? keySpan.end.offset + 2 // Null value have no span. Skip ":". + : getContentSensitiveEnd(valueNode), + ), + lineEnding: getLineEnding(yaml), + + // Only called when the next node is present. Never before. + nextBlockNodeInfo: () { + final nextKeyNode = map.nodes.keys.elementAt(entryIndex + 1) as YamlNode; + final nextKeySpan = nextKeyNode.span.start; + + return ( + nearestLineEnding: yaml.lastIndexOf('\n', nextKeySpan.offset), + nextNodeColStart: nextKeySpan.column + ); + }, + ); } /// Performs the string operation on [yamlEdit] to achieve the effect of -/// removing the [keyNode] from the map, bearing in mind that this is a flow +/// 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) { +SourceEdit _removeFromFlowMap(YamlEditor yamlEdit, YamlMap map, Object? key) { + final (index: _, :keyNode, :valueNode) = getYamlMapEntry(map, key); + var start = keyNode.span.start.offset; var end = valueNode.span.end.offset; final yaml = yamlEdit.toString(); diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart index 0e28ac1a8..bde5406c1 100644 --- a/pkgs/yaml_edit/lib/src/utils.dart +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -2,10 +2,13 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:math'; + import 'package:source_span/source_span.dart'; import 'package:yaml/yaml.dart'; import 'editor.dart'; +import 'source_edit.dart'; import 'wrap.dart'; /// Invoke [fn] while setting [yamlWarningCallback] to [warn], and restore @@ -273,6 +276,312 @@ String getLineEnding(String yaml) { return windowsNewlines > unixNewlines ? '\r\n' : '\n'; } +final _nonSpaceMatch = RegExp(r'[^ \t]'); + +/// Skip empty lines and returns the offset of the last possible line ending +/// only if the [offset] is a valid offset within the [yaml] string that +/// points to first line ending. +int indexOfLastLineEnding(String yaml, int offset) { + if (yaml.isEmpty || offset == -1) return yaml.length; + + final lastOffset = yaml.length - 1; + var currentOffset = min(offset, lastOffset); + + if (yaml[currentOffset] case '\r' || '\n') { + var lineEndingIndex = currentOffset; + ++currentOffset; + + scanner: + while (currentOffset <= lastOffset) { + switch (yaml[currentOffset]) { + case ' ' || '\t': + { + currentOffset = yaml.indexOf(_nonSpaceMatch, currentOffset); + + // We scanned till the end of the string and found nothing. + if (currentOffset == -1) { + lineEndingIndex = lastOffset; + break scanner; + } + } + + case '\r' || '\n': + { + lineEndingIndex = currentOffset; + ++currentOffset; + } + + default: + break scanner; + } + } + + return lineEndingIndex; + } + + return currentOffset; +} + +/// Backtracks from the [start] offset and looks for the nearest character +/// that is not a separation space (tab/space) that can be used to declare a +/// nested compact block map +/// +/// ```yaml +/// # In a block list +/// - key: value +/// next: value +/// +/// --- +/// # In an explicit key and its value +/// +/// ? key: value +/// next: value +/// : key: value +/// next: value +/// ``` +/// +/// If a line feed `\n` is encountered first then `compactCharOffset` defaults +/// to -1. Otherwise, only returns a non-negative `compactCharOffset` if +/// `?`, `-` or `:` were seen. +({int compactCharOffset, int lineEndingIndex}) indexOfCompactChar( + String yaml, + int start, +) { + var startOffset = max(0, start - 1); + + scanner: + while (true) { + switch (yaml[startOffset]) { + // This is either indent or separation space + case ' ' || '\t': + { + startOffset = yaml.lastIndexOf(_nonSpaceMatch, startOffset); + if (startOffset == -1) break scanner; + } + + case '\r' || '\n': + return (compactCharOffset: -1, lineEndingIndex: startOffset); + + /// Block sequences and explicit keys/values can be used to declare block + /// maps in a compact-inline notation. + /// + /// - a: b + /// c: d + /// + /// OR as an explicit key with its explicit value + /// + /// ? a: b + /// c: d + /// : e: f + /// g: h + /// + /// See "Example 8.19 Compact Block Mappings" at + /// https://yaml.org/spec/1.2.2/#822-block-mappings + case '-' || '?' || ':': + return (compactCharOffset: startOffset, lineEndingIndex: -1); + + default: + break scanner; + } + } + + return (compactCharOffset: -1, lineEndingIndex: -1); +} + +typedef NextBlockNodeInfo = ({int nearestLineEnding, int nextNodeColStart}); +typedef BlockNodeOffset = ({int start, int end}); + +/// Removes a block entry and its line ending from a [blockCollection] using the +/// [nodeToRemoveOffset] provided. Any trailing comments are also removed. +/// +/// If [blockCollection] is a [YamlMap], the chunk removed corresponds to the +/// key-value pair represented by the offset. If [blockCollection] is a +/// [YamlList], the chunk represents a single element within the list. +/// +/// [nextBlockNodeInfo] is only called if the [blockCollection] has at least +/// 2 entries and the entry being removed is not the last entry in the +/// collection. +SourceEdit removeBlockCollectionEntry( + String yaml, { + required YamlNode blockCollection, + required bool isFirstEntry, + required bool isSingleEntry, + required bool isLastEntry, + required BlockNodeOffset nodeToRemoveOffset, + required String lineEnding, + required NextBlockNodeInfo Function() nextBlockNodeInfo, +}) { + final isBlockList = blockCollection is YamlList; + + assert( + isBlockList || blockCollection is YamlMap, + 'Expected a block map/list', + ); + + var makeNextNodeCompact = false; + var (:start, :end) = nodeToRemoveOffset; + + if (start != 0 && !isSingleEntry) { + /// Try making it compact in case the collection's parent is a: + /// - block sequence + /// - explicit key + /// - explicit key's explicit value. + if (isFirstEntry) { + final (:compactCharOffset, :lineEndingIndex) = indexOfCompactChar( + yaml, + start, + ); + + if (compactCharOffset != -1) { + start = compactCharOffset + 2; // Skip separation space. + makeNextNodeCompact = true; + } else { + start = lineEndingIndex + 1; + } + } else { + /// If not possible, just consume this node's indent. This prevents this + /// node from interfering with the next node. + start = yaml.lastIndexOf('\n', start) + 1; + } + } + + var replacement = ''; + + // Skip empty lines to the last line break + end = indexOfLastLineEnding(yaml, yaml.indexOf('\n', end - 1)); + end = min(++end, yaml.length); // Mark it for removal + + if (isSingleEntry) { + /// Take back the last line ending even if multiple were skipped. This node + /// is sandwiched between other elements not in this map. + if (end < yaml.length - 1) { + end -= lineEnding == '\r\n' ? 2 : 1; + } + + replacement = isBlockList ? '[]' : '{}'; + } else if (!isLastEntry) { + final (:nearestLineEnding, :nextNodeColStart) = nextBlockNodeInfo(); + final trueEndOffset = end - 1; + + /// Make compact only if we are pointing to same line break from different + /// node extremes. This can only be true if we are removing the first + /// entry. + /// + /// ** For block lists ** + /// + /// [*] Before: + /// + /// - - value + /// - next + /// + /// [*] After: + /// + /// - - next + /// + /// ** For block maps ** + /// + /// [*] Before: + /// + /// - key: value + /// next: value + /// + /// [*] After: + /// + /// - next: value + if (makeNextNodeCompact) { + /// Leaving comments here has no effect since we are removing the first + /// entry of the collection. The next node will be the first. However, the + /// inline comment hugging the outermost node in the current line will be + /// consumed. + /// + /// ** For Block Lists ** + /// + /// [*] Before: + /// + /// - - value # Comment + /// # Next Comment + /// - next + /// + /// [*] After: + /// + /// - # Next Comment + /// - next + /// + /// ** For Block Maps ** + /// + /// [*] Before: + /// + /// - key: value # Comment + /// # Next Comment + /// next: value + /// + /// [*] After: + /// + /// - # Next Comment + /// next-key: value + end = nearestLineEnding == trueEndOffset ? end + nextNodeColStart : end; + } else if (nearestLineEnding != trueEndOffset) { + /// We shouldn't assume the string will always have a flow node. Edits + /// may be made to a YAML string which declared block scalars initially. + /// Better safe than sorry. + /// + /// Truncate any dangling comments which may change the structure of + /// existing nodes. We must point to the same offset after every empty + /// line has been skipped. Mimic (intelligently) what the parser does + /// with lexing. + /// + /// ** For Block Lists ** + /// + /// [*] Before: + /// + /// - >+ + /// folded keep + /// + /// - target # Comment + /// # Next Comment + /// # Another Comment + /// - next + /// + /// [*] After: + /// + /// - >+ + /// folded keep + /// + /// - next + /// + /// ** For Block Maps ** + /// + /// [*] Before: + /// + /// key: >+ + /// value + /// + /// target-key: value # Comment + /// # Next Comment + /// # Another Comment + /// + /// next: here + /// + /// [*] After: + /// + /// key: >+ + /// value + /// + /// next: here + /// + /// We preserved the string's meaning without much effort. + end = nearestLineEnding + 1; + } + } else { + end = max( + yaml.lastIndexOf('\n', blockCollection.span.end.offset) + 1, + end, + ); + } + + return SourceEdit(start, end - start, replacement); +} + extension YamlNodeExtension on YamlNode { /// Returns the [CollectionStyle] of `this` if `this` is [YamlMap] or /// [YamlList]. diff --git a/pkgs/yaml_edit/test/remove_test.dart b/pkgs/yaml_edit/test/remove_test.dart index 4742b5608..f601584ed 100644 --- a/pkgs/yaml_edit/test/remove_test.dart +++ b/pkgs/yaml_edit/test/remove_test.dart @@ -241,6 +241,25 @@ dev_dependencies: retry:'''); doc.remove(['dev_dependencies', 'retry']); }); + + test('Removes node with preceding block scalar', () { + final doc = YamlEditor(''' +key: >+ + folded with keep chomping + +target-key: value # Comment + # Comment +next: value +'''); + + doc.remove(['target-key']); + expect(doc.toString(), ''' +key: >+ + folded with keep chomping + +next: value +'''); + }); }); group('flow map', () { @@ -564,6 +583,65 @@ b: } ]); }); + + test('Removes comments that may interfere with block scalar', () { + final doc = YamlEditor(''' +- >+ + folded keep chomp + +- - - |+ # Nested + literal keep chomp + + - value # May interfere + # With block node + +- top # With + # Funky + + # Comments + + # Comment +'''); + + doc.remove([1, 1]); + + expect(doc.toString(), ''' +- >+ + folded keep chomp + +- - - |+ # Nested + literal keep chomp + +- top # With + # Funky + + # Comments + + # Comment +'''); + + doc.remove([1]); + + expect(doc.toString(), ''' +- >+ + folded keep chomp + +- top # With + # Funky + + # Comments + + # Comment +'''); + + doc.remove([1]); + + expect(doc.toString(), ''' +- >+ + folded keep chomp + +'''); + }); }); group('flow list', () { From 0dc457ad393214707aee7b6f8316975c172cac20 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Sun, 9 Nov 2025 22:44:49 +0000 Subject: [PATCH 02/10] Use correct node span from the next node --- pkgs/yaml_edit/lib/src/list_mutations.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/yaml_edit/lib/src/list_mutations.dart b/pkgs/yaml_edit/lib/src/list_mutations.dart index acf1f36b9..a82e0876c 100644 --- a/pkgs/yaml_edit/lib/src/list_mutations.dart +++ b/pkgs/yaml_edit/lib/src/list_mutations.dart @@ -332,7 +332,7 @@ SourceEdit _removeFromBlockList( final hyphenOffset = yaml.lastIndexOf( '-', - span.length == 0 ? offset : offset - 1, + nextNodeSpan.length == 0 ? offset : offset - 1, ); final nearestLineEnding = yaml.lastIndexOf('\n', hyphenOffset); From 52b8c9005fa10c3564cf0760ecaa8e6bb3532c76 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 28 Nov 2025 06:10:54 +0000 Subject: [PATCH 03/10] Check first non-space match for compact-inline notation char --- pkgs/yaml_edit/lib/src/utils.dart | 70 +++++++++++++++---------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart index bde5406c1..d14c04392 100644 --- a/pkgs/yaml_edit/lib/src/utils.dart +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -347,45 +347,41 @@ int indexOfLastLineEnding(String yaml, int offset) { String yaml, int start, ) { - var startOffset = max(0, start - 1); - - scanner: - while (true) { - switch (yaml[startOffset]) { - // This is either indent or separation space - case ' ' || '\t': - { - startOffset = yaml.lastIndexOf(_nonSpaceMatch, startOffset); - if (startOffset == -1) break scanner; - } - - case '\r' || '\n': - return (compactCharOffset: -1, lineEndingIndex: startOffset); - - /// Block sequences and explicit keys/values can be used to declare block - /// maps in a compact-inline notation. - /// - /// - a: b - /// c: d - /// - /// OR as an explicit key with its explicit value - /// - /// ? a: b - /// c: d - /// : e: f - /// g: h - /// - /// See "Example 8.19 Compact Block Mappings" at - /// https://yaml.org/spec/1.2.2/#822-block-mappings - case '-' || '?' || ':': - return (compactCharOffset: startOffset, lineEndingIndex: -1); + /// Look back past the indent/separation space. + final startOffset = max( + 0, + yaml.lastIndexOf(_nonSpaceMatch, max(0, start - 1)), + ); - default: - break scanner; - } - } + return switch (yaml[startOffset]) { + '\r' || '\n' => (compactCharOffset: -1, lineEndingIndex: startOffset), - return (compactCharOffset: -1, lineEndingIndex: -1); + /// Block sequences and explicit keys/values can be used to declare block + /// maps/sequences in a compact-inline notation. + /// + /// - a: b + /// c: d + /// + /// - - a + /// - b + /// + /// OR as an explicit key with its explicit value + /// + /// ? a: b + /// c: d + /// : e: f + /// g: h + /// + /// ? - sequence + /// - as key + /// : - sequence + /// - as value + /// + /// See "Example 8.19 Compact Block Mappings" at + /// https://yaml.org/spec/1.2.2/#822-block-mappings + '-' || '?' || ':' => (compactCharOffset: startOffset, lineEndingIndex: -1), + _ => (compactCharOffset: -1, lineEndingIndex: -1) + }; } typedef NextBlockNodeInfo = ({int nearestLineEnding, int nextNodeColStart}); From 15c014e51acce80b30cda9e46e273bba51a771a2 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:51:29 +0000 Subject: [PATCH 04/10] Preserve non-indented comments --- pkgs/yaml_edit/lib/src/utils.dart | 125 ++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 25 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart index d14c04392..a188bde04 100644 --- a/pkgs/yaml_edit/lib/src/utils.dart +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -281,45 +281,120 @@ final _nonSpaceMatch = RegExp(r'[^ \t]'); /// Skip empty lines and returns the offset of the last possible line ending /// only if the [offset] is a valid offset within the [yaml] string that /// points to first line ending. -int indexOfLastLineEnding(String yaml, int offset) { +/// +/// The [blockIndent] is used to truncate any comments more indented than the +/// parent collection that may affect other block entries within the collection +/// that may have block scalars. +int indexOfLastLineEnding( + String yaml, { + required int offset, + required int blockIndent, +}) { if (yaml.isEmpty || offset == -1) return yaml.length; final lastOffset = yaml.length - 1; var currentOffset = min(offset, lastOffset); - if (yaml[currentOffset] case '\r' || '\n') { - var lineEndingIndex = currentOffset; - ++currentOffset; - - scanner: - while (currentOffset <= lastOffset) { - switch (yaml[currentOffset]) { - case ' ' || '\t': - { - currentOffset = yaml.indexOf(_nonSpaceMatch, currentOffset); - - // We scanned till the end of the string and found nothing. - if (currentOffset == -1) { - lineEndingIndex = lastOffset; - break scanner; + // Unsafe. Cannot start our scanner state machine in an unguarded state. + if (yaml[currentOffset] != '\r' && yaml[currentOffset] != '\n') { + return currentOffset; + } + + var lineEndingIndex = currentOffset; + + // Skip empty lines and any comments indented more than the block entry. Such + // comments are hazardous to block scalars. + scanner: + while (currentOffset <= lastOffset) { + switch (yaml[currentOffset]) { + case '\r': + { + // Skip carriage return if possible. No use to us if we have a line + // feed after. + if (currentOffset < lastOffset && yaml[currentOffset + 1] == '\n') { + ++currentOffset; + } + + continue indentChecker; + } + + indentChecker: + case '\n': + { + lineEndingIndex = currentOffset; + ++currentOffset; + + if (currentOffset >= lastOffset) { + lineEndingIndex = lastOffset; + break scanner; + } + + final offsetAfterIndent = yaml.indexOf(RegExp('[^ ]'), currentOffset); + + // No more characters! + if (offsetAfterIndent == -1) { + lineEndingIndex = lastOffset; + break scanner; + } + + final indent = offsetAfterIndent - currentOffset; + currentOffset = offsetAfterIndent; + final charAfterIndent = yaml[currentOffset]; + + if (charAfterIndent case '\r' || '\n') { + continue scanner; + } else if (indent > blockIndent) { + // If more indented than the entry, always attempt to truncate the + // comment or skip it as an empty line. + if (charAfterIndent == '\t') { + continue skipIfEmpty; + } else if (charAfterIndent == '#') { + continue truncateComment; } } - case '\r' || '\n': - { - lineEndingIndex = currentOffset; - ++currentOffset; + break scanner; + } + + // Guarded by indentChecker. Force tabs to be associated with empty lines + // if seen past the indent. + skipIfEmpty: + case '\t': + { + final nonSpace = yaml.indexOf(_nonSpaceMatch, currentOffset); + + if (nonSpace == -1) { + lineEndingIndex = lastOffset; + } else if (yaml[nonSpace] case '\r' || '\n') { + currentOffset = nonSpace; + continue scanner; } - default: break scanner; - } - } + } + + // Guarded by indentChecker. This ensures we only skip comments indented + // more than the entry itself. + truncateComment: + case '#': + { + final lineFeedOffset = yaml.indexOf('\n', currentOffset); + + if (lineFeedOffset == -1) { + lineEndingIndex = lastOffset; + break scanner; + } - return lineEndingIndex; + currentOffset = lineFeedOffset; + continue indentChecker; + } + + default: + break scanner; + } } - return currentOffset; + return lineEndingIndex; } /// Backtracks from the [start] offset and looks for the nearest character From 04c6a214e10324c628d05525d3c924c1ff86db99 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:54:28 +0000 Subject: [PATCH 05/10] Refactor nested code and fix indent issue --- pkgs/yaml_edit/lib/src/list_mutations.dart | 1 + pkgs/yaml_edit/lib/src/map_mutations.dart | 1 + pkgs/yaml_edit/lib/src/utils.dart | 203 +++++++-------------- 3 files changed, 63 insertions(+), 142 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/list_mutations.dart b/pkgs/yaml_edit/lib/src/list_mutations.dart index a82e0876c..c26bd1270 100644 --- a/pkgs/yaml_edit/lib/src/list_mutations.dart +++ b/pkgs/yaml_edit/lib/src/list_mutations.dart @@ -315,6 +315,7 @@ SourceEdit _removeFromBlockList( return removeBlockCollectionEntry( yaml, blockCollection: list, + collectionIndent: getListIndentation(yaml, list), isFirstEntry: index == 0, isSingleEntry: listSize == 1, isLastEntry: index >= listSize - 1, diff --git a/pkgs/yaml_edit/lib/src/map_mutations.dart b/pkgs/yaml_edit/lib/src/map_mutations.dart index 3b655c95d..2d66db503 100644 --- a/pkgs/yaml_edit/lib/src/map_mutations.dart +++ b/pkgs/yaml_edit/lib/src/map_mutations.dart @@ -178,6 +178,7 @@ SourceEdit _removeFromBlockMap(YamlEditor yamlEdit, YamlMap map, Object? key) { return removeBlockCollectionEntry( yaml, blockCollection: map, + collectionIndent: getMapIndentation(yaml, map), isFirstEntry: entryIndex == 0, isSingleEntry: mapSize == 1, isLastEntry: entryIndex >= mapSize - 1, diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart index a188bde04..8ad62b3bd 100644 --- a/pkgs/yaml_edit/lib/src/utils.dart +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -475,6 +475,7 @@ typedef BlockNodeOffset = ({int start, int end}); SourceEdit removeBlockCollectionEntry( String yaml, { required YamlNode blockCollection, + required int collectionIndent, required bool isFirstEntry, required bool isSingleEntry, required bool isLastEntry, @@ -492,11 +493,31 @@ SourceEdit removeBlockCollectionEntry( var makeNextNodeCompact = false; var (:start, :end) = nodeToRemoveOffset; - if (start != 0 && !isSingleEntry) { - /// Try making it compact in case the collection's parent is a: - /// - block sequence - /// - explicit key - /// - explicit key's explicit value. + // Skip empty lines to the last line break + end = indexOfLastLineEnding( + yaml, + offset: yaml.indexOf('\n', end - 1), + blockIndent: collectionIndent, + ); + + end = min(++end, yaml.length); // Mark it for removal + + if (isSingleEntry) { + // Take back the last line ending even if multiple were skipped. This node + // is sandwiched between other elements not in this map. + end = end >= yaml.length - 1 ? end : (end - (lineEnding == '\r\n' ? 2 : 1)); + return SourceEdit(start, end - start, isBlockList ? '[]' : '{}'); + } else if (isLastEntry) { + start = yaml.lastIndexOf('\n', start) + 1; + end = max(yaml.lastIndexOf('\n', blockCollection.span.end.offset) + 1, end); + return SourceEdit(start, end - start, ''); + } + + if (start != 0) { + // Try making it compact in case the collection's parent is a: + // - block sequence + // - explicit key + // - explicit key's explicit value. if (isFirstEntry) { final (:compactCharOffset, :lineEndingIndex) = indexOfCompactChar( yaml, @@ -510,147 +531,45 @@ SourceEdit removeBlockCollectionEntry( start = lineEndingIndex + 1; } } else { - /// If not possible, just consume this node's indent. This prevents this - /// node from interfering with the next node. + // If not possible, just consume this node's indent. This prevents this + // node from interfering with the next node. start = yaml.lastIndexOf('\n', start) + 1; } } - var replacement = ''; - - // Skip empty lines to the last line break - end = indexOfLastLineEnding(yaml, yaml.indexOf('\n', end - 1)); - end = min(++end, yaml.length); // Mark it for removal - - if (isSingleEntry) { - /// Take back the last line ending even if multiple were skipped. This node - /// is sandwiched between other elements not in this map. - if (end < yaml.length - 1) { - end -= lineEnding == '\r\n' ? 2 : 1; - } - - replacement = isBlockList ? '[]' : '{}'; - } else if (!isLastEntry) { - final (:nearestLineEnding, :nextNodeColStart) = nextBlockNodeInfo(); - final trueEndOffset = end - 1; - - /// Make compact only if we are pointing to same line break from different - /// node extremes. This can only be true if we are removing the first - /// entry. - /// - /// ** For block lists ** - /// - /// [*] Before: - /// - /// - - value - /// - next - /// - /// [*] After: - /// - /// - - next - /// - /// ** For block maps ** - /// - /// [*] Before: - /// - /// - key: value - /// next: value - /// - /// [*] After: - /// - /// - next: value - if (makeNextNodeCompact) { - /// Leaving comments here has no effect since we are removing the first - /// entry of the collection. The next node will be the first. However, the - /// inline comment hugging the outermost node in the current line will be - /// consumed. - /// - /// ** For Block Lists ** - /// - /// [*] Before: - /// - /// - - value # Comment - /// # Next Comment - /// - next - /// - /// [*] After: - /// - /// - # Next Comment - /// - next - /// - /// ** For Block Maps ** - /// - /// [*] Before: - /// - /// - key: value # Comment - /// # Next Comment - /// next: value - /// - /// [*] After: - /// - /// - # Next Comment - /// next-key: value - end = nearestLineEnding == trueEndOffset ? end + nextNodeColStart : end; - } else if (nearestLineEnding != trueEndOffset) { - /// We shouldn't assume the string will always have a flow node. Edits - /// may be made to a YAML string which declared block scalars initially. - /// Better safe than sorry. - /// - /// Truncate any dangling comments which may change the structure of - /// existing nodes. We must point to the same offset after every empty - /// line has been skipped. Mimic (intelligently) what the parser does - /// with lexing. - /// - /// ** For Block Lists ** - /// - /// [*] Before: - /// - /// - >+ - /// folded keep - /// - /// - target # Comment - /// # Next Comment - /// # Another Comment - /// - next - /// - /// [*] After: - /// - /// - >+ - /// folded keep - /// - /// - next - /// - /// ** For Block Maps ** - /// - /// [*] Before: - /// - /// key: >+ - /// value - /// - /// target-key: value # Comment - /// # Next Comment - /// # Another Comment - /// - /// next: here - /// - /// [*] After: - /// - /// key: >+ - /// value - /// - /// next: here - /// - /// We preserved the string's meaning without much effort. - end = nearestLineEnding + 1; - } - } else { - end = max( - yaml.lastIndexOf('\n', blockCollection.span.end.offset) + 1, - end, - ); - } - - return SourceEdit(start, end - start, replacement); + final (:nearestLineEnding, :nextNodeColStart) = nextBlockNodeInfo(); + final trueEndOffset = end - 1; + + // Make compact only if we are pointing to same line break from different + // node extremes. This can only be true if we are removing the first + // entry. + // + // ** For block lists ** + // + // [*] Before: + // + // - - value + // - next + // + // [*] After: + // + // - - next + // + // ** For block maps ** + // + // [*] Before: + // + // - key: value + // next: value + // + // [*] After: + // + // - next: value + end = makeNextNodeCompact && nearestLineEnding == trueEndOffset + ? end + nextNodeColStart + : end; + + return SourceEdit(start, end - start, ''); } extension YamlNodeExtension on YamlNode { From 0a07e653d7343a799367f89ffc9696e0e84b8632 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:55:43 +0000 Subject: [PATCH 06/10] Handle empty block sequences correctly --- pkgs/yaml_edit/lib/src/list_mutations.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/list_mutations.dart b/pkgs/yaml_edit/lib/src/list_mutations.dart index c26bd1270..7613da65c 100644 --- a/pkgs/yaml_edit/lib/src/list_mutations.dart +++ b/pkgs/yaml_edit/lib/src/list_mutations.dart @@ -312,6 +312,9 @@ SourceEdit _removeFromBlockList( final yaml = yamlEdit.toString(); final span = nodeToRemove.span; + final isEmptySpan = span.length == 0; // Just the '-' + final end = getContentSensitiveEnd(nodeToRemove); + return removeBlockCollectionEntry( yaml, blockCollection: list, @@ -320,10 +323,11 @@ SourceEdit _removeFromBlockList( isSingleEntry: listSize == 1, isLastEntry: index >= listSize - 1, nodeToRemoveOffset: ( - start: span.length == 0 - ? span.start.offset - : yaml.lastIndexOf('-', span.start.offset - 1), - end: getContentSensitiveEnd(nodeToRemove) + start: yaml.lastIndexOf( + '-', + isEmptySpan ? span.start.offset : span.start.offset - 1, + ), + end: isEmptySpan ? end + 1 : end, ), lineEnding: getLineEnding(yaml), nextBlockNodeInfo: () { From db122d5d07e5d29843f2b8544f1337b29c3bc9bf Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 01:04:27 +0000 Subject: [PATCH 07/10] Update old tests to match changes --- pkgs/yaml_edit/test/remove_test.dart | 9 ++------- pkgs/yaml_edit/test/testdata/output/issue_55.golden | 2 +- .../test/testdata/output/remove_block_list.golden | 2 +- .../test/testdata/output/remove_nested_key.golden | 2 +- .../testdata/output/remove_nested_key_with_null.golden | 2 +- .../output/remove_nested_key_with_trailing_comma.golden | 2 +- pkgs/yaml_edit/test/windows_test.dart | 8 ++------ 7 files changed, 9 insertions(+), 18 deletions(-) diff --git a/pkgs/yaml_edit/test/remove_test.dart b/pkgs/yaml_edit/test/remove_test.dart index f601584ed..ce8c2f8ce 100644 --- a/pkgs/yaml_edit/test/remove_test.dart +++ b/pkgs/yaml_edit/test/remove_test.dart @@ -131,7 +131,6 @@ c: 3 doc.remove([0, 'b']); expect(doc.toString(), equals(''' - a: 1 - c: 3 ''')); }); @@ -184,9 +183,7 @@ b: 2 a: 1 '''); doc.remove(['a']); - expect(doc.toString(), equals(''' -{} -''')); + expect(doc.toString(), equals('{}')); }); test('last element should return flow empty map (2)', () { @@ -331,9 +328,7 @@ next: value - 0 '''); doc.remove([0]); - expect(doc.toString(), equals(''' -[] -''')); + expect(doc.toString(), equals('[]')); }); test('last element should return flow empty list (2)', () { diff --git a/pkgs/yaml_edit/test/testdata/output/issue_55.golden b/pkgs/yaml_edit/test/testdata/output/issue_55.golden index f752a1438..5fd59296f 100644 --- a/pkgs/yaml_edit/test/testdata/output/issue_55.golden +++ b/pkgs/yaml_edit/test/testdata/output/issue_55.golden @@ -12,4 +12,4 @@ environment: sdk: ^3.0.0 dependencies: dev_dependencies: - {} + {} \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden index 7aa388064..bbef925f6 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden @@ -33,4 +33,4 @@ - foo: true - {} - nested: - {} + {} \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden index 011f1c1cf..7326d7776 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden @@ -9,4 +9,4 @@ B: --- A: true B: - {} + {} \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden index b0e771cdd..85f49f738 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden @@ -4,4 +4,4 @@ B: --- A: true B: - {} + {} \ No newline at end of file diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden index c93c46a97..1010ed43b 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden @@ -4,4 +4,4 @@ B: --- A: true B: - {} + {} \ No newline at end of file diff --git a/pkgs/yaml_edit/test/windows_test.dart b/pkgs/yaml_edit/test/windows_test.dart index 50f79e743..835519919 100644 --- a/pkgs/yaml_edit/test/windows_test.dart +++ b/pkgs/yaml_edit/test/windows_test.dart @@ -161,9 +161,7 @@ c: 3\r - 0\r '''); doc.remove([0]); - expect(doc.toString(), equals(''' -[]\r -''')); + expect(doc.toString(), equals('[]')); expectYamlBuilderValue(doc, []); }); @@ -204,9 +202,7 @@ c: 3\r a: 1\r '''); doc.remove(['a']); - expect(doc.toString(), equals(''' -{}\r -''')); + expect(doc.toString(), equals('{}')); expectYamlBuilderValue(doc, {}); }); From db86a0cf59f177c0cbe6c114a14c7485ae53ce2d Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 01:09:32 +0000 Subject: [PATCH 08/10] Add tests --- pkgs/yaml_edit/test/remove_test.dart | 4 + pkgs/yaml_edit/test/utils_test.dart | 179 +++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/pkgs/yaml_edit/test/remove_test.dart b/pkgs/yaml_edit/test/remove_test.dart index ce8c2f8ce..0623efe2f 100644 --- a/pkgs/yaml_edit/test/remove_test.dart +++ b/pkgs/yaml_edit/test/remove_test.dart @@ -246,6 +246,9 @@ key: >+ target-key: value # Comment # Comment + # Indented, removed + +# Not indented, kept next: value '''); @@ -254,6 +257,7 @@ next: value key: >+ folded with keep chomping +# Not indented, kept next: value '''); }); diff --git a/pkgs/yaml_edit/test/utils_test.dart b/pkgs/yaml_edit/test/utils_test.dart index 57f0b2a77..7ec1e6bc6 100644 --- a/pkgs/yaml_edit/test/utils_test.dart +++ b/pkgs/yaml_edit/test/utils_test.dart @@ -445,4 +445,183 @@ a: expect(() => assertValidScalar([1]), throwsArgumentError); }); }); + + group('compact char test', () { + test( + 'Returns a valid index of the character used to declare block node' + ' in compact-inline notation for an explicit key', + () { + const yaml = ''' +? - block + - sequence + +? key: value + another: value +'''; + + final mapKeys = (loadYamlNode( + yaml, + ) as YamlMap) + .nodes + .keys + .cast() + .toList(); + + // Compact block lists + expect( + indexOfCompactChar( + yaml, + mapKeys.first.span.start.offset, + ), + equals((compactCharOffset: 0, lineEndingIndex: -1)), + ); + + // Compact block maps + expect( + indexOfCompactChar( + yaml, + mapKeys[1].span.start.offset, + ), + + // Skip first "?" + equals( + (compactCharOffset: yaml.indexOf('?', 1), lineEndingIndex: -1), + ), + ); + }, + ); + + test( + 'Returns a valid index of the character used to declare block node' + ' in compact-inline notation for an explicit value', + () { + /// A key/value is always implicit unless an explicit key is seen. + /// + /// See paragraph after example 8.17 in Block Mappings section. + const yaml = ''' +? key +: - block + - sequence + +? another +: explict: block + value: node +'''; + + final values = (loadYamlNode( + yaml, + ) as YamlMap) + .nodes + .values + .toList(); + + final firstExplicitValueChar = yaml.indexOf(':'); + + // Compact block lists + expect( + indexOfCompactChar(yaml, values.first.span.start.offset), + equals( + (compactCharOffset: firstExplicitValueChar, lineEndingIndex: -1), + ), + ); + + // Compact block maps + expect( + indexOfCompactChar(yaml, values[1].span.start.offset), + + // Skip first ":" + equals( + ( + compactCharOffset: yaml.indexOf(':', firstExplicitValueChar + 1), + lineEndingIndex: -1 + ), + ), + ); + }, + ); + + test( + 'Returns a valid index of the character used to declare block node' + ' in compact-inline notation for a block sequence', + () { + const yaml = ''' +- - block + - sequence + +- key: value + another: value +'''; + + final elements = (loadYamlNode( + yaml, + ) as YamlList) + .nodes; + + // Compact block lists + expect( + indexOfCompactChar(yaml, elements.first.span.start.offset), + equals((compactCharOffset: 0, lineEndingIndex: -1)), + ); + + // Compact block maps + expect( + indexOfCompactChar(yaml, elements[1].span.start.offset), + + // Skip first "?" + equals( + (compactCharOffset: yaml.lastIndexOf('-'), lineEndingIndex: -1), + ), + ); + }, + ); + + test('Returns index of line break when not compact', () { + const yaml = ''' +- + - not compact +- + ? + - key not compact + : + - not compact +'''; + + final list = (loadYamlNode(yaml) as YamlList).nodes; + + // First nested block sequence is not compact + expect( + indexOfCompactChar(yaml, list[0].span.start.offset), + equals((compactCharOffset: -1, lineEndingIndex: yaml.indexOf('\n'))), + ); + + // Explicit key & value not compact. + final map = list[1] as YamlMap; + + final entries = map.nodes; + + expect( + indexOfCompactChar( + yaml, + entries.keys.cast().first.span.start.offset, + ), + equals(( + compactCharOffset: -1, + lineEndingIndex: yaml.indexOf('\n', map.span.start.offset) + )), + ); + + expect( + indexOfCompactChar(yaml, entries.values.first.span.start.offset), + equals( + ( + compactCharOffset: -1, + lineEndingIndex: yaml.indexOf( + '\n', + yaml.indexOf(':', map.span.start.offset), + ) + ), + ), + ); + }); + }); } From f9beeed5d0da394cece483bec919d2fb78686145 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 02:07:44 +0000 Subject: [PATCH 09/10] Default to map start offset for the first entry --- pkgs/yaml_edit/lib/src/map_mutations.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkgs/yaml_edit/lib/src/map_mutations.dart b/pkgs/yaml_edit/lib/src/map_mutations.dart index 2d66db503..04c27d0ee 100644 --- a/pkgs/yaml_edit/lib/src/map_mutations.dart +++ b/pkgs/yaml_edit/lib/src/map_mutations.dart @@ -183,7 +183,8 @@ SourceEdit _removeFromBlockMap(YamlEditor yamlEdit, YamlMap map, Object? key) { isSingleEntry: mapSize == 1, isLastEntry: entryIndex >= mapSize - 1, nodeToRemoveOffset: ( - start: keySpan.start.offset, + // A block map only exists because of its first key. + start: entryIndex == 0 ? map.span.start.offset : keySpan.start.offset, end: valueNode.span.length == 0 ? keySpan.end.offset + 2 // Null value have no span. Skip ":". : getContentSensitiveEnd(valueNode), From b5f7884ced1cdcffa93b935ce71fec51e8a2c309 Mon Sep 17 00:00:00 2001 From: Kelvin Kavisi <68240897+kekavc24@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:55:09 +0000 Subject: [PATCH 10/10] Preserve EOF line endings --- pkgs/yaml_edit/lib/src/utils.dart | 8 +++++--- pkgs/yaml_edit/test/remove_test.dart | 6 +++--- pkgs/yaml_edit/test/testdata/output/issue_55.golden | 2 +- .../test/testdata/output/remove_block_list.golden | 2 +- .../test/testdata/output/remove_nested_key.golden | 2 +- .../testdata/output/remove_nested_key_with_null.golden | 2 +- .../output/remove_nested_key_with_trailing_comma.golden | 2 +- pkgs/yaml_edit/test/windows_test.dart | 4 ++-- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/pkgs/yaml_edit/lib/src/utils.dart b/pkgs/yaml_edit/lib/src/utils.dart index 8ad62b3bd..e446ad92c 100644 --- a/pkgs/yaml_edit/lib/src/utils.dart +++ b/pkgs/yaml_edit/lib/src/utils.dart @@ -503,9 +503,11 @@ SourceEdit removeBlockCollectionEntry( end = min(++end, yaml.length); // Mark it for removal if (isSingleEntry) { - // Take back the last line ending even if multiple were skipped. This node - // is sandwiched between other elements not in this map. - end = end >= yaml.length - 1 ? end : (end - (lineEnding == '\r\n' ? 2 : 1)); + // Preserve when sandwiched or if an EOF line ending was present. + if (end < yaml.length || yaml.endsWith('\n')) { + end -= lineEnding == '\r\n' ? 2 : 1; + } + return SourceEdit(start, end - start, isBlockList ? '[]' : '{}'); } else if (isLastEntry) { start = yaml.lastIndexOf('\n', start) + 1; diff --git a/pkgs/yaml_edit/test/remove_test.dart b/pkgs/yaml_edit/test/remove_test.dart index 0623efe2f..f3adc7c7c 100644 --- a/pkgs/yaml_edit/test/remove_test.dart +++ b/pkgs/yaml_edit/test/remove_test.dart @@ -183,7 +183,7 @@ b: 2 a: 1 '''); doc.remove(['a']); - expect(doc.toString(), equals('{}')); + expect(doc.toString(), equals('{}\n')); }); test('last element should return flow empty map (2)', () { @@ -247,7 +247,7 @@ key: >+ target-key: value # Comment # Comment # Indented, removed - + # Not indented, kept next: value '''); @@ -332,7 +332,7 @@ next: value - 0 '''); doc.remove([0]); - expect(doc.toString(), equals('[]')); + expect(doc.toString(), equals('[]\n')); }); test('last element should return flow empty list (2)', () { diff --git a/pkgs/yaml_edit/test/testdata/output/issue_55.golden b/pkgs/yaml_edit/test/testdata/output/issue_55.golden index 5fd59296f..f752a1438 100644 --- a/pkgs/yaml_edit/test/testdata/output/issue_55.golden +++ b/pkgs/yaml_edit/test/testdata/output/issue_55.golden @@ -12,4 +12,4 @@ environment: sdk: ^3.0.0 dependencies: dev_dependencies: - {} \ No newline at end of file + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden index bbef925f6..7aa388064 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_block_list.golden @@ -33,4 +33,4 @@ - foo: true - {} - nested: - {} \ No newline at end of file + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden index 7326d7776..011f1c1cf 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key.golden @@ -9,4 +9,4 @@ B: --- A: true B: - {} \ No newline at end of file + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden index 85f49f738..b0e771cdd 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_null.golden @@ -4,4 +4,4 @@ B: --- A: true B: - {} \ No newline at end of file + {} diff --git a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden index 1010ed43b..c93c46a97 100644 --- a/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden +++ b/pkgs/yaml_edit/test/testdata/output/remove_nested_key_with_trailing_comma.golden @@ -4,4 +4,4 @@ B: --- A: true B: - {} \ No newline at end of file + {} diff --git a/pkgs/yaml_edit/test/windows_test.dart b/pkgs/yaml_edit/test/windows_test.dart index 835519919..16eae4981 100644 --- a/pkgs/yaml_edit/test/windows_test.dart +++ b/pkgs/yaml_edit/test/windows_test.dart @@ -161,7 +161,7 @@ c: 3\r - 0\r '''); doc.remove([0]); - expect(doc.toString(), equals('[]')); + expect(doc.toString(), equals('[]\r\n')); expectYamlBuilderValue(doc, []); }); @@ -202,7 +202,7 @@ c: 3\r a: 1\r '''); doc.remove(['a']); - expect(doc.toString(), equals('{}')); + expect(doc.toString(), equals('{}\r\n')); expectYamlBuilderValue(doc, {}); });