diff --git a/lib/src/editor.dart b/lib/src/editor.dart index 54775cc..d44b171 100644 --- a/lib/src/editor.dart +++ b/lib/src/editor.dart @@ -241,11 +241,25 @@ class YamlEditor { if (path.isEmpty) { final start = _contents.span.start.offset; - final end = getContentSensitiveEnd(_contents); + var end = getContentSensitiveEnd(_contents); final lineEnding = getLineEnding(_yaml); - final edit = SourceEdit( - start, end - start, yamlEncodeBlock(valueNode, 0, lineEnding)); - + end = skipAndExtractCommentsInBlock( + _yaml, + end, + null, + lineEnding: lineEnding, + greedy: true, + ).$1; + var encoded = yamlEncodeBlock(valueNode, 0, lineEnding); + encoded = normalizeEncodedBlock( + _yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: end, + update: valueNode, + updateAsString: encoded, + skipPreservationCheck: true, + ); + final edit = SourceEdit(start, end - start, encoded); return _performEdit(edit, path, valueNode); } @@ -483,7 +497,7 @@ class YamlEditor { if (!containsKey(map, keyOrIndex)) { return _pathErrorOrElse(path, path.take(i + 1), map, orElse); } - final keyNode = getKeyNode(map, keyOrIndex); + final (_, keyNode) = getKeyNode(map, keyOrIndex); if (checkAlias) { if (_aliases.contains(keyNode)) throw AliasException(path, keyNode); diff --git a/lib/src/equality.dart b/lib/src/equality.dart index 0c6a952..fae0ed3 100644 --- a/lib/src/equality.dart +++ b/lib/src/equality.dart @@ -87,8 +87,10 @@ int deepHashCode(Object? value) { } /// Returns the [YamlNode] corresponding to the provided [key]. -YamlNode getKeyNode(YamlMap map, Object? key) { - return map.nodes.keys.firstWhere((node) => deepEquals(node, key)) as YamlNode; +(int index, YamlNode keyNode) getKeyNode(YamlMap map, Object? key) { + return map.nodes.keys.indexed.firstWhere( + (value) => deepEquals(value.$2, key), + ) as (int, YamlNode); } /// Returns the [YamlNode] after the [YamlNode] corresponding to the provided diff --git a/lib/src/list_mutations.dart b/lib/src/list_mutations.dart index 17da6dd..0b0df51 100644 --- a/lib/src/list_mutations.dart +++ b/lib/src/list_mutations.dart @@ -29,18 +29,21 @@ SourceEdit updateInList( final listIndentation = getListIndentation(yaml, list); final indentation = listIndentation + getIndentation(yamlEdit); final lineEnding = getLineEnding(yaml); - valueString = - yamlEncodeBlock(wrapAsYamlNode(newValue), indentation, lineEnding); + + final encoded = yamlEncodeBlock( + wrapAsYamlNode(newValue), + indentation, + lineEnding, + ); + valueString = encoded; /// We prefer the compact nested notation for collections. /// - /// By virtue of [yamlEncodeBlockString], collections automatically + /// By virtue of [yamlEncodeBlock], collections automatically /// have the necessary line endings. if ((newValue is List && (newValue as List).isNotEmpty) || (newValue is Map && (newValue as Map).isNotEmpty)) { valueString = valueString.substring(indentation); - } else if (currValue.collectionStyle == CollectionStyle.BLOCK) { - valueString += lineEnding; } var end = getContentSensitiveEnd(currValue); @@ -50,6 +53,19 @@ SourceEdit updateInList( valueString = ' $valueString'; } + // Aggressively skip all comments + final (offsetOfLastComment, _) = + skipAndExtractCommentsInBlock(yaml, end, null, lineEnding); + end = offsetOfLastComment; + + valueString = normalizeEncodedBlock( + yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: end, + update: newValue, + updateAsString: valueString, + ); + return SourceEdit(offset, end - offset, valueString); } else { valueString = yamlEncodeFlow(newValue); @@ -146,7 +162,7 @@ SourceEdit _appendToBlockList( valueString = valueString.substring(newIndentation); } - return (listIndentation, '- $valueString$lineEnding'); + return (listIndentation, '- $valueString'); } /// Formats [item] into a new node for flow lists. diff --git a/lib/src/map_mutations.dart b/lib/src/map_mutations.dart index 67665d9..234c7ee 100644 --- a/lib/src/map_mutations.dart +++ b/lib/src/map_mutations.dart @@ -36,7 +36,7 @@ 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 (_, keyNode) = getKeyNode(map, key); final valueNode = map.nodes[keyNode]!; if (map.style == CollectionStyle.FLOW) { @@ -83,13 +83,14 @@ SourceEdit _addToBlockMap( } } - var valueString = yamlEncodeBlock(newValue, newIndentation, lineEnding); + final valueString = yamlEncodeBlock(newValue, newIndentation, lineEnding); + if (isCollection(newValue) && !isFlowYamlCollectionNode(newValue) && !isEmpty(newValue)) { - formattedValue += '$keyString:$lineEnding$valueString$lineEnding'; + formattedValue += '$keyString:$lineEnding$valueString'; } else { - formattedValue += '$keyString: $valueString$lineEnding'; + formattedValue += '$keyString: $valueString'; } return SourceEdit(offset, 0, formattedValue); @@ -127,12 +128,17 @@ SourceEdit _replaceInBlockMap( YamlEditor yamlEdit, YamlMap map, Object? key, YamlNode newValue) { final yaml = yamlEdit.toString(); final lineEnding = getLineEnding(yaml); - final newIndentation = - getMapIndentation(yaml, map) + getIndentation(yamlEdit); + final mapIndentation = getMapIndentation(yaml, map); + final newIndentation = mapIndentation + getIndentation(yamlEdit); + + final (_, keyNode) = getKeyNode(map, key); + + var valueAsString = yamlEncodeBlock( + wrapAsYamlNode(newValue), + newIndentation, + lineEnding, + ); - final keyNode = getKeyNode(map, key); - var valueAsString = - yamlEncodeBlock(wrapAsYamlNode(newValue), newIndentation, lineEnding); if (isCollection(newValue) && !isFlowYamlCollectionNode(newValue) && !isEmpty(newValue)) { @@ -150,9 +156,43 @@ SourceEdit _replaceInBlockMap( var end = getContentSensitiveEnd(map.nodes[key]!); /// `package:yaml` parses empty nodes in a way where the start/end of the - /// empty value node is the end of the key node, so we have to adjust for - /// this. - if (end < start) end = start; + /// empty value node is the end of the key node. + /// + /// In our case, we need to ensure that any line-breaks are included in the + /// edit such that: + /// 1. We account for `\n` after a key within other keys or at the start + /// Example.. + /// a: + /// b: value + /// + /// or.. + /// a: value + /// b: + /// c: value + /// + /// 2. We don't suggest edits that are not within the string bounds because + /// of the `\n` we need to account for in Rule 1 above. This could be a + /// key: + /// * At the index `0` but it's the only key + /// * At the end in a map with more than one key + end = start == yaml.length + ? start + : end < start + ? start + 1 + : end; + + // Aggressively skip all comments + final (offsetOfLastComment, _) = + skipAndExtractCommentsInBlock(yaml, end, null, lineEnding: lineEnding); + end = offsetOfLastComment; + + valueAsString = normalizeEncodedBlock( + yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: end, + update: newValue, + updateAsString: valueAsString, + ); return SourceEdit(start, end - start, valueAsString); } diff --git a/lib/src/strings.dart b/lib/src/strings.dart index dcb1b72..c3fe9e7 100644 --- a/lib/src/strings.dart +++ b/lib/src/strings.dart @@ -106,7 +106,7 @@ String? _tryYamlEncodeFolded(String string, int indentSize, String lineEnding) { /// Remove trailing `\n` & white-space to ease string folding var trimmed = string.trimRight(); - final stripped = string.substring(trimmed.length); + var stripped = string.substring(trimmed.length); final trimmedSplit = trimmed.replaceAll('\n', lineEnding + indent).split(lineEnding); @@ -137,9 +137,30 @@ String? _tryYamlEncodeFolded(String string, int indentSize, String lineEnding) { return previous + lineEnding + updated; }); - return '>-\n' + stripped = stripped.replaceAll('\n', lineEnding); // Mild paranoia + final ignoreTrailingLineBreak = stripped.endsWith(lineEnding); + + // We ignore it with conviction as explained below. + if (ignoreTrailingLineBreak) { + stripped = stripped.substring(0, stripped.length - 1); + } + + /// If indeed we have a trailing line, we apply a `chomping hack`. We use a + /// `clip indicator` (no chomping indicator) if we need to ignore the `\n` + /// and `strip indicator` if not to remove any trailing indents. + /// + /// The caller of this method, that is, [yamlEncodeBlock] will apply a + /// dangling `\n` that will\should be normalized by + /// [normalizeEncodedBlock] which allows trailing `\n` for [folded] + /// strings such that: + /// * If we had a string `"my string \n"`: + /// 1. This function excludes it and it becomes `>\nmy string ` + /// 2. [yamlEncodeBlock] applies `\n` that we skipped. + /// 2. [normalizeEncodedBlock] ignores the trailing `\n` for folded + /// string by default. + return '>${ignoreTrailingLineBreak ? '' : '-'}\n' '$indent$trimmed' - '${stripped.replaceAll('\n', lineEnding + indent)}'; + '${stripped.replaceAll(lineEnding, lineEnding + indent)}'; } /// Attempts to encode a [string] as a _YAML literal string_ and apply the @@ -170,13 +191,41 @@ String? _tryYamlEncodeLiteral( // encoded in literal mode. if (_hasUnprintableCharacters(string)) return null; + final indent = ' ' * indentSize; + // TODO: Are there other strings we can't encode in literal mode? + final trimmed = string.trimRight(); - final indent = ' ' * indentSize; + // Mild paranoia + var stripped = string + .substring( + trimmed.length, + ) + .replaceAll('\n', lineEnding); + + final ignoreTrailingLineBreak = stripped.endsWith(lineEnding); + + // We ignore it with conviction as explained below. + if (ignoreTrailingLineBreak) { + stripped = stripped.substring(0, stripped.length - 1); + } - /// Simplest block style. - /// * https://yaml.org/spec/1.2.2/#812-literal-style - return '|-\n$indent${string.replaceAll('\n', lineEnding + indent)}'; + /// If indeed we have a trailing line, we apply a `chomping hack`. We use a + /// `clip indicator` (no chomping indicator) if we need to ignore the `\n` + /// and `strip indicator` if not to remove any trailing indents. + /// + /// The caller of this method, that is, [yamlEncodeBlock] will apply a + /// dangling `\n` that will\should be normalized by + /// [normalizeEncodedBlock] which allows trailing `\n` for [literal] + /// strings such that: + /// * If we had a string `"my string \n"`: + /// 1. This function excludes it and it becomes `|\nmy string ` + /// 2. [yamlEncodeBlock] applies `\n` that we skipped. + /// 2. [normalizeEncodedBlock] ignores the trailing `\n` for literal + /// string by default. + return '|${ignoreTrailingLineBreak ? '' : '-'}\n' + '$indent${trimmed.replaceAll('\n', lineEnding + indent)}' + '${stripped.replaceAll(lineEnding, lineEnding + indent)}'; } /// Encodes a flow [YamlScalar] based on the provided [YamlScalar.style]. @@ -276,64 +325,56 @@ String yamlEncodeFlow(YamlNode value) { } /// Returns [value] with the necessary formatting applied in a block context. -String yamlEncodeBlock( - YamlNode value, - int indentation, - String lineEnding, -) { +/// +/// It is recommended that callers of this method also make a call to +/// [normalizeEncodedBlock] with this [value] as the `update` and output +/// of this call as the `updateAsString` to prune any dangling line-break. +String yamlEncodeBlock(YamlNode value, int indentation, String lineEnding) { const additionalIndentation = 2; - if (!isBlockNode(value)) return yamlEncodeFlow(value); + if (!isBlockNode(value)) return yamlEncodeFlow(value) + lineEnding; final newIndentation = indentation + additionalIndentation; if (value is YamlList) { - if (value.isEmpty) return '${' ' * indentation}[]'; + if (value.isEmpty) return '${' ' * indentation}[]$lineEnding'; - Iterable safeValues; + return value.nodes.fold('', (string, element) { + var valueString = yamlEncodeBlock(element, newIndentation, lineEnding); - final children = value.nodes; - - safeValues = children.map((child) { - var valueString = yamlEncodeBlock(child, newIndentation, lineEnding); - if (isCollection(child) && !isFlowYamlCollectionNode(child)) { + if (isCollection(element) && !isFlowYamlCollectionNode(element)) { valueString = valueString.substring(newIndentation); } - return '${' ' * indentation}- $valueString'; + return '$string${' ' * indentation}- $valueString'; }); - - return safeValues.join(lineEnding); } else if (value is YamlMap) { - if (value.isEmpty) return '${' ' * indentation}{}'; + if (value.isEmpty) return '${' ' * indentation}{}$lineEnding'; - return value.nodes.entries.map((entry) { + return value.nodes.entries.fold('', (string, entry) { final MapEntry(:key, :value) = entry; final safeKey = yamlEncodeFlow(key as YamlNode); - final formattedKey = ' ' * indentation + safeKey; + var formattedKey = ' ' * indentation + safeKey; - final formattedValue = yamlEncodeBlock( - value, - newIndentation, - lineEnding, - ); + final formattedValue = yamlEncodeBlock(value, newIndentation, lineEnding); /// Empty collections are always encoded in flow-style, so new-line must - /// be avoided - if (isCollection(value) && !isEmpty(value)) { - return '$formattedKey:$lineEnding$formattedValue'; - } + /// be avoided. Otherwise, begin the collection on a new line. + formattedKey = '$formattedKey:' + '${isCollection(value) && !isEmpty(value) ? lineEnding : " "}'; - return '$formattedKey: $formattedValue'; - }).join(lineEnding); + return '$string$formattedKey$formattedValue'; + }); } - return _yamlEncodeBlockScalar( + final encodedScalar = _yamlEncodeBlockScalar( value as YamlScalar, newIndentation, lineEnding, ); + + return encodedScalar + lineEnding; } /// List of unprintable characters. diff --git a/lib/src/utils.dart b/lib/src/utils.dart index c1e1755..39430c2 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -273,6 +273,243 @@ String getLineEnding(String yaml) { return windowsNewlines > unixNewlines ? '\r\n' : '\n'; } +/// Extracts comments for a node that is replaced within a [YamlMap] or +/// [YamlList] or a top-level [YamlScalar] of the [yaml] string provided. +/// +/// [currentEndOffset] represents the end offset of [YamlScalar] or [YamlList] +/// or [YamlMap] being replaced, that is, `end + 1`. +/// +/// [nextStartOffset] represents the start offset of the next [YamlNode]. +/// May be null if the current [YamlNode] being replaced is the last node +/// in a [YamlScalar] or [YamlList] or if its the only top-level [YamlScalar]. +/// If not sure of the next [YamlNode]'s [nextStartOffset] pass in null and +/// allow this function to handle that manually. +/// +/// Do note that this function has no context of the structure of the [yaml] +/// but assumes the caller does and requires comments based on the offsets +/// provided and thus, may be erroneus since it exclusively scans for `#` +/// delimiter or extracts the comments between the [currentEndOffset] and +/// [nextStartOffset] if both are provided. +/// +/// Returns the `endOffset` of the last comment extracted that is `end + 1` +/// and a `List comments`. It is recommended (but not necessary) that +/// the caller checks the `endOffset` is still within the bounds of the [yaml]. +(int endOffset, List comments) skipAndExtractCommentsInBlock( + String yaml, + int currentEndOffset, + int? nextStartOffset, { + String lineEnding = '\n', + bool greedy = false, +}) { + /// If [nextStartOffset] is null, this may be the last element in a collection + /// and thus we have to check and extract comments manually. + /// + /// Also, the caller may not be sure where the next node starts. + if (nextStartOffset == null) { + final comments = []; + + /// Skips white-space while extracting comments. + /// + /// Returns [null] if the end of the [yaml] was encountered while + /// skipping any white-space. Otherwise, returns the [index] of the next + /// non-white-space character. + (int? firstLineBreakOffset, int? nextIndex) skipWhitespace(int index) { + int? firstLineBreak; + int? nextIndex = index; + + while (true) { + if (nextIndex == yaml.length) { + nextIndex = null; + break; + } + + final char = yaml[nextIndex!]; + + if (char == lineEnding && firstLineBreak == null) { + firstLineBreak = nextIndex; + } + + if (char.trim().isNotEmpty) break; + ++nextIndex; + } + + if (firstLineBreak != null) firstLineBreak += 1; // Skip it if not null + return (firstLineBreak, nextIndex); + } + + /// Returns the [currentOffset] if [greedy] is true. Otherwise, attempts + /// returning the [firstLineBreakOffset] if not null if [greedy] is false. + int earlyBreakOffset(int currentOffset, int? firstLineBreakOffset) { + if (greedy) return currentOffset; + return firstLineBreakOffset ?? currentOffset; + } + + var currentOffset = currentEndOffset; + + while (true) { + if (currentOffset == yaml.length) break; + + var leadingChar = yaml[currentOffset].trim(); + var indexOfCommentStart = -1; + + int? firstLineBreak; + + if (leadingChar.isEmpty) { + final (firstLE, nextIndex) = skipWhitespace(currentEndOffset); + + /// If the next index is null, it means we reached the end of the + /// string. Since we lazily evaluated the string, attempt to return the + /// first [lineEnding] we encountered only if not null. + if (nextIndex == null) { + currentOffset = earlyBreakOffset(yaml.length, firstLE); + break; + } + + firstLineBreak = firstLE; + currentOffset = nextIndex; + leadingChar = yaml[currentOffset]; + } + + /// We need comments only, nothing else. This may be pointless but will + /// help us avoid extracting comments when provided random offsets + /// within a string. + if (leadingChar == '#') indexOfCommentStart = currentOffset; + + /// This is a mindless assumption that the last character was either + /// `\n` or [white-space] or the last erroneus offset provided. + /// + /// Since we lazily evaluated the string, attempt to return the + /// first [lineEnding] we encountered only if not null. + if (indexOfCommentStart == -1) { + currentOffset = earlyBreakOffset(currentOffset, firstLineBreak); + break; + } + + final indexOfLineBreak = yaml.indexOf(lineEnding, currentOffset); + final isEnd = indexOfLineBreak == -1; + + final comment = yaml + .substring(indexOfCommentStart, isEnd ? null : indexOfLineBreak) + .trim(); + + if (comment.isNotEmpty) comments.add(comment); + + if (isEnd) { + currentOffset += comment.length; + break; + } + currentOffset = indexOfLineBreak + 1; // Skip line-break eagerly + } + + return (currentOffset, comments); + } + + return ( + nextStartOffset, + yaml.substring(currentEndOffset, nextStartOffset).split(lineEnding).fold( + [], + (buffer, current) { + final comment = current.trim(); + if (comment.isNotEmpty) buffer.add(comment); + return buffer; + }, + ) + ); +} + +/// Normalizes an encoded [YamlNode] encoded as a string by pruning any +/// dangling line-breaks. +/// +/// This function checks the last `YamlNode` of the [update] that is a +/// `YamlScalar` and removes any dangling line-break within the +/// [updateAsString]. +/// +/// Line breaks are allowed if a: +/// 1. [YamlScalar] has [ScalarStyle.LITERAL] or [ScalarStyle.FOLDED] +/// 2. [YamlScalar] has [ScalarStyle.PLAIN] or [ScalarStyle.ANY] and its +/// raw value is a [String] with a trailing line break. +/// 3. [YamlNode] being replaced has a line break. +/// +/// [skipPreservationCheck] should always remain false if updating a value +/// within a [YamlList] or [YamlMap] that isn't an existing top-level +/// [YamlNode]. +String normalizeEncodedBlock( + String yaml, { + required String lineEnding, + required int nodeToReplaceEndOffset, + required YamlNode update, + required String updateAsString, + bool skipPreservationCheck = false, +}) { + var terminalNode = update; + + /// Checks if the dangling line break should be allowed within the deepest + /// [YamlNode] that is a [YamlScalar]. + bool allowInYamlScalar(ScalarStyle style, dynamic value) { + /// We never normalize a literal/folded string irrespective of + /// its position. We allow the block indicators to define how line break + /// will be treated + if (style == ScalarStyle.LITERAL || style == ScalarStyle.FOLDED) { + return true; + } + + // Allow trailing line break if the raw value has a explicit line break. + if (style == ScalarStyle.PLAIN || style == ScalarStyle.ANY) { + return value is String && + (value.endsWith('\n') || value.endsWith('\r\n')); + } + + return false; + } + + loop: + while (terminalNode is! YamlScalar) { + switch (terminalNode) { + case YamlList list: + { + if (list.isEmpty) { + terminalNode = list; + break loop; + } + + terminalNode = list.nodes.last; + } + + case YamlMap map: + { + if (map.isEmpty) { + terminalNode = map; + break loop; + } + + terminalNode = map.nodes.entries.last.value; + } + } + } + + /// The node may end up being an empty [YamlMap] or [YamlList] or + /// [YamlScalar]. + if (terminalNode case YamlScalar(style: var style, value: var value) + when allowInYamlScalar(style, value)) { + return updateAsString; + } + + if (yaml.isNotEmpty && !skipPreservationCheck) { + // Move it back one position. Offset passed in is/should be exclusive + final offsetBeforeEnd = nodeToReplaceEndOffset > 0 + ? nodeToReplaceEndOffset - 1 + : nodeToReplaceEndOffset; + + /// Leave as is. The [update] is: + /// 1. An element not at the end of [YamlList] or [YamlMap] + /// 2. [YamlNode] replaced had a `\n` + if (yaml[offsetBeforeEnd] == '\n') return updateAsString; + } + + // Remove trailing line-break by default. + return updateAsString.trimRight(); +} + extension YamlNodeExtension on YamlNode { /// Returns the [CollectionStyle] of `this` if `this` is [YamlMap] or /// [YamlList].