diff --git a/lib/src/editor.dart b/lib/src/editor.dart index 54775cc..03b4f49 100644 --- a/lib/src/editor.dart +++ b/lib/src/editor.dart @@ -241,11 +241,26 @@ 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, + endOfNodeOffset: end, + lineEnding: lineEnding, + greedy: true, + ).endOffset; + + 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 +498,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..fea58a7 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,21 @@ SourceEdit updateInList( valueString = ' $valueString'; } + // Aggressively skip all comments + end = skipAndExtractCommentsInBlock( + yaml, + endOfNodeOffset: end, + lineEnding: lineEnding, + ).endOffset; + + valueString = normalizeEncodedBlock( + yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: end, + update: newValue, + updateAsString: valueString, + ); + return SourceEdit(offset, end - offset, valueString); } else { valueString = yamlEncodeFlow(newValue); @@ -112,22 +130,41 @@ SourceEdit _appendToFlowList( /// block list. SourceEdit _appendToBlockList( YamlEditor yamlEdit, YamlList list, YamlNode item) { - var (indentSize, valueToIndent) = _formatNewBlock(yamlEdit, list, item); - var formattedValue = '${' ' * indentSize}$valueToIndent'; + /// A block list can never be empty since a `-` must be seen for it to be a + /// valid block sequence. + /// + /// See description of: + /// https://yaml.org/spec/1.2.2/#82-block-collection-styles. + assert( + list.isNotEmpty, + 'A YamlList encoded as CollectionStyle.BLOCK must have a value', + ); final yaml = yamlEdit.toString(); - var offset = list.span.end.offset; + final lineEnding = getLineEnding(yaml); - // Adjusts offset to after the trailing newline of the last entry, if it - // exists - if (list.isNotEmpty) { - final lastValueSpanEnd = list.nodes.last.span.end.offset; - final nextNewLineIndex = yaml.indexOf('\n', lastValueSpanEnd - 1); - if (nextNewLineIndex == -1) { - formattedValue = getLineEnding(yaml) + formattedValue; - } else { - offset = nextNewLineIndex + 1; - } + // Lazily skip all comments and white-space at the end. + final offset = skipAndExtractCommentsInBlock( + yaml, + endOfNodeOffset: list.nodes.last.span.end.offset, + lineEnding: lineEnding, + ).endOffset; + + var (indentSize, formattedValue) = _formatNewBlock(yamlEdit, list, item); + + formattedValue = normalizeEncodedBlock( + yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: offset, + update: item, + updateAsString: formattedValue, + ); + + formattedValue = '${' ' * indentSize}$formattedValue'; + + // Apply line ending incase it's missing + if (yaml[offset - 1] != '\n') { + formattedValue = '$lineEnding$formattedValue'; } return SourceEdit(offset, 0, formattedValue); @@ -146,7 +183,7 @@ SourceEdit _appendToBlockList( valueString = valueString.substring(newIndentation); } - return (listIndentation, '- $valueString$lineEnding'); + return (listIndentation, '- $valueString'); } /// Formats [item] into a new node for flow lists. @@ -239,7 +276,7 @@ SourceEdit _insertInBlockList( /// ``` (bool isNested, int offset) _isNestedInBlockList( int currentSequenceOffset, String yaml) { - final startIndex = currentSequenceOffset - 1; + final startOffset = currentSequenceOffset - 1; /// Indicates the element we are inserting before is at index `0` of the list /// at the root of the yaml @@ -248,10 +285,10 @@ SourceEdit _insertInBlockList( /// /// - foo /// ^ Inserting before this - if (startIndex < 0) return (false, 0); + if (startOffset < 0) return (false, 0); - final newLineStart = yaml.lastIndexOf('\n', startIndex); - final seqStart = yaml.lastIndexOf('-', startIndex); + final newLineStart = yaml.lastIndexOf('\n', startOffset); + final seqStart = yaml.lastIndexOf('-', startOffset); /// Indicates that a `\n` is closer to the last `-`. Meaning this list is not /// nested. @@ -308,70 +345,84 @@ 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; + final yaml = yamlEdit.toString(); + final yamlSize = yaml.length; - return SourceEdit(start, end - start, '[]'); + final lineEnding = getLineEnding(yaml); + final YamlNode(:span) = nodeToRemove; + + var startOffset = span.start.offset; + startOffset = + span.length == 0 ? startOffset : yaml.lastIndexOf('-', startOffset - 1); + + var endOffset = getContentSensitiveEnd(nodeToRemove); + + /// YamlMap may have `null` value for the last key and we need to ensure the + /// correct [endOffset] is provided to [skipAndExtractCommentsInBlock], + /// otherwise [skipAndExtractCommentsInBlock] may prematurely return an + /// incorrect offset because it immediately saw `:` + if (nodeToRemove is YamlMap && + endOffset < yamlSize && + nodeToRemove.nodes.entries.last.value.value == null) { + endOffset += 1; } - final yaml = yamlEdit.toString(); - final span = nodeToRemove.span; + // We remove any content belonging to [nodeToRemove] greedily + endOffset = skipAndExtractCommentsInBlock( + yaml, + endOfNodeOffset: endOffset == startOffset ? endOffset + 1 : endOffset, + lineEnding: lineEnding, + greedy: true, + ).endOffset; - /// Adjust the end to clear the new line after the end too. - /// - /// We do this because we suspect that our users will want the inline - /// comments to disappear too. - final nextNewLine = yaml.indexOf('\n', end); - if (nextNewLine != -1) { - end = nextNewLine + 1; - } + final listSize = list.length; + + final isSingleElement = listSize == 1; + final isLastElementInList = index == listSize - 1; + final isLastInYaml = endOffset == yamlSize; + + final replacement = isSingleElement ? '[]' : ''; - /// If the value is empty - if (span.length == 0) { - var start = span.start.offset; - return SourceEdit(start, end - start, ''); + /// Adjust [startIndent] to include any indent this element may have had + /// to prevent it from interfering with the indent of the next [YamlNode] + /// which isn't in this list. We move it back if: + /// 1. The [nodeToRemove] is the last element in a [list] with more than + /// one element. + /// 2. It also isn't the first element in the yaml. + /// + /// Doing this only for the last element ensures that any value's indent is + /// automatically given to the next element in the list such that, + /// + /// 1. If nested: + /// - - value + /// ^ This space goes to the next element that ends up here + /// + /// 2. If not nested, then the next element gets the indent if any is present. + if (isLastElementInList && startOffset != 0 && !isSingleElement) { + final index = yaml.lastIndexOf('\n', startOffset); + startOffset = index == -1 ? startOffset : index + 1; } - /// -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; - } + /// We intentionally [skipAndExtractCommentsInBlock] greedily which also + /// consumes the next [YamlNode]'s indent. + /// + /// For elements at the last index, we need to reclaim the indent belonging + /// to the next node not in the list and optionally include a line break if + /// if it is the only element. See [reclaimIndentAndLinebreak] for more info. + if (isLastElementInList && !isLastInYaml) { + endOffset = reclaimIndentAndLinebreak( + yaml, + endOffset, + isSingle: isSingleElement, + ); + } else if (isLastInYaml && yaml[endOffset - 1] == '\n' && isSingleElement) { + /// Remove any dangling line-break that may have been part of the yaml: + /// -`\r\n` = 2 + /// - `\n` = 1 + endOffset -= lineEnding == '\n' ? 1 : 2; } - return SourceEdit(start, end - start, ''); + return SourceEdit(startOffset, endOffset - startOffset, replacement); } /// Returns a [SourceEdit] describing the change to be made on [yamlEdit] to diff --git a/lib/src/map_mutations.dart b/lib/src/map_mutations.dart index 67665d9..d0a526a 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,45 @@ 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; + + // Skip comments lazily + end = skipAndExtractCommentsInBlock( + yaml, + endOfNodeOffset: end, + lineEnding: lineEnding, + ).endOffset; + + valueAsString = normalizeEncodedBlock( + yaml, + lineEnding: lineEnding, + nodeToReplaceEndOffset: end, + update: newValue, + updateAsString: valueAsString, + ); return SourceEdit(start, end - start, valueAsString); } @@ -174,62 +216,73 @@ SourceEdit _replaceInFlowMap( SourceEdit _removeFromBlockMap( YamlEditor yamlEdit, YamlMap map, YamlNode keyNode, YamlNode valueNode) { final keySpan = keyNode.span; - var end = getContentSensitiveEnd(valueNode); + final yaml = yamlEdit.toString(); + final yamlSize = yaml.length; + 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, '{}'); - } + final (keyIndex, _) = getKeyNode(map, keyNode); - var start = keySpan.start.offset; + var startOffset = keySpan.start.offset; - /// Adjust the end to clear the new line after the end too. + /// Null values have an invalid offset. Include colon. /// - /// 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; - } + /// See issue open in `package: yaml`. + var endOffset = valueNode.span.length == 0 + ? keySpan.end.offset + 2 + : getContentSensitiveEnd(valueNode) + 1; // Overeager to avoid issues - final nextNode = getNextKeyNode(map, keyNode); + if (endOffset > yamlSize) endOffset -= 1; - if (start > 0) { - final lastHyphen = yaml.lastIndexOf('-', start - 1); - final lastNewLine = yaml.lastIndexOf(lineEnding, start - 1); - if (lastHyphen > lastNewLine) { - start = lastHyphen + 2; + endOffset = skipAndExtractCommentsInBlock( + yaml, + endOfNodeOffset: endOffset, + lineEnding: lineEnding, + greedy: true, + ).endOffset; - /// 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 isSingleEntry = mapSize == 1; + final isLastEntryInMap = keyIndex == mapSize - 1; + final isLastNodeInYaml = endOffset == yamlSize; + + final replacement = isSingleEntry ? '{}' : ''; + + /// Adjust [startIndent] to include any indent this element may have had + /// to prevent it from interfering with the indent of the next [YamlNode] + /// which isn't in this map. We move it back if: + /// 1. The entry is the last entry in a [map] with more than one element. + /// 2. It also isn't the first entry of map in the yaml. + /// + /// Doing this only for the last element ensures that any value's indent is + /// automatically given to the next entry in the map. + if (isLastEntryInMap && startOffset != 0 && !isSingleEntry) { + final index = yaml.lastIndexOf('\n', startOffset); + startOffset = index == -1 ? startOffset : index + 1; } - return SourceEdit(start, end - start, ''); + /// We intentionally [skipAndExtractCommentsInBlock] greedily which also + /// consumes the next [YamlNode]'s indent. + /// + /// For elements at the last index, we need to reclaim the indent belonging + /// to the next node not in the map and optionally include a line break if + /// if it is the only entry. See [reclaimIndentAndLinebreak] for more info. + if (isLastEntryInMap && !isLastNodeInYaml) { + endOffset = reclaimIndentAndLinebreak( + yaml, + endOffset, + isSingle: isSingleEntry, + ); + } else if (isLastNodeInYaml && yaml[endOffset - 1] == '\n' && isSingleEntry) { + /// Include any trailing line break that may have been part of the yaml: + /// -`\r\n` = 2 + /// - `\n` = 1 + endOffset -= lineEnding == '\n' ? 1 : 2; + } + + return SourceEdit(startOffset, endOffset - startOffset, replacement); } /// Performs the string operation on [yamlEdit] to achieve the effect of diff --git a/lib/src/strings.dart b/lib/src/strings.dart index dcb1b72..66c5891 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,36 @@ 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-break, we apply a `chomping hack`. + /// + /// We use a `clip indicator` (no chomping indicator) if we need to ignore the + /// `\n` and `strip indicator` to remove any trailing line-break and its + /// indent. + /// + /// The caller of this method, that is, [yamlEncodeBlock], will apply a + /// dangling `\n` that must be normalized by [normalizeEncodedBlock] which + /// allows trailing `\n` for [folded] strings such that: + /// * If we had a string "example \n": + /// 1. This function excludes the line-break at the end and it becomes: + /// - ">" + "\n" + + "example " + /// + /// 2. [yamlEncodeBlock] applies a dangling `\n` that we skipped and it + /// becomes: + /// - ">" + "\n" + + "example " + \n` + /// + /// 3. [normalizeEncodedBlock] never prunes the dangling `\n` applied for + /// folded strings 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 +197,47 @@ 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-break, we apply a `chomping hack`. + /// + /// We use a `clip indicator` (no chomping indicator) if we need to ignore the + /// `\n` and `strip indicator` to remove any trailing line-break and its + /// indent. + /// + /// The caller of this method, that is, [yamlEncodeBlock], will apply a + /// dangling `\n` that must be normalized by [normalizeEncodedBlock] which + /// allows trailing `\n` for [literal] strings such that: + /// * If we had a string "example \n": + /// 1. This function excludes the line-break at the end and it becomes: + /// - ">" + "\n" + + "example " + /// + /// 2. [yamlEncodeBlock] applies a dangling `\n` that we skipped and it + /// becomes: + /// - ">" + "\n" + + "example " + \n` + /// + /// 3. [normalizeEncodedBlock] never prunes the dangling `\n` applied for + /// literal strings 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 +337,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}[]'; - - Iterable safeValues; + if (value.isEmpty) return '${' ' * indentation}[]$lineEnding'; - final children = value.nodes; + return value.nodes.fold('', (string, element) { + var valueString = yamlEncodeBlock(element, newIndentation, lineEnding); - 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..60c3f97 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -273,6 +273,272 @@ String getLineEnding(String yaml) { return windowsNewlines > unixNewlines ? '\r\n' : '\n'; } +/// Skips and extracts comments for a replaced/removed [YamlNode]. +/// +/// [endOfNodeOffset] represents the end offset of the [YamlNode] being +/// replaced, that is, `end + 1`. +/// +/// [nextStartOffset] represents the start offset of the next [YamlNode]. +/// Should be null if the current [YamlNode] being replaced is: +/// - The terminal node in a top-level [YamlList] +/// - The last entry or value in an entry in a top-level [YamlMap] +/// - The only top-level [YamlScalar]. +/// +/// It is recommended to ignore or pass in `null` for [nextStartOffset] since +/// this function immediately exits once no comments are found. +/// +/// If [greedy] is `true`, whitespace and any line breaks are skipped. If +/// `false`, this function looks for comments lazily and returns the offset of +/// the first line break that was encountered if no comments were found. +/// +/// 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 [endOfNodeOffset] 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, { + required int endOfNodeOffset, + 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 = []; + + /// Nested function that 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); + } + + /// Nested function that returns the [currentOffset] if [greedy] is true. + /// Otherwise, attempts to return the [firstLineBreakOffset] if not null. + int earlyBreakOffset(int currentOffset, int? firstLineBreakOffset) { + if (greedy) return currentOffset; + return firstLineBreakOffset ?? currentOffset; + } + + var currentOffset = endOfNodeOffset; + + while (true) { + if (currentOffset >= yaml.length) break; + + var leadingChar = yaml[currentOffset].trim(); + var indexOfCommentStart = -1; + + int? firstLineBreak; + + if (leadingChar.isEmpty) { + final (firstLE, nextIndex) = skipWhitespace(currentOffset); + + // We skipped everything to the end of the yaml + if (nextIndex == null) { + currentOffset = earlyBreakOffset(yaml.length, firstLE); + break; + } + + firstLineBreak = firstLE; + currentOffset = nextIndex; + leadingChar = yaml[currentOffset]; + } + + /// We need comments only! This may be pointless but will help us exit + /// early 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. + 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; + } + + return (endOffset: currentOffset, comments: comments); + } + + return ( + endOffset: nextStartOffset, + comments: + yaml.substring(endOfNodeOffset, nextStartOffset).split(lineEnding).fold( + [], + (buffer, current) { + final comment = current.trim(); + if (comment.isNotEmpty) buffer.add(comment); + return buffer; + }, + ) + ); +} + +/// Reclaims any indent greedily skipped by [skipAndExtractCommentsInBlock] +/// and returns the start `offset` (inclusive). +/// +/// If [isSingle] is `true`, then the `offset` of the line-break is included. +/// It is excluded if `false`. +/// +/// It is recommended that this function is called when removing the last +/// [YamlNode] in a block [YamlMap] or [YamlList]. +int reclaimIndentAndLinebreak( + String yaml, + int currentOffset, { + required bool isSingle, +}) { + var indexOfLineBreak = yaml.lastIndexOf('\n', currentOffset); + + /// In case, this isn't the only element, we ignore the line-break while + /// reclaiming the indent. As the element that remains, will have a line + /// break the next node needs to indicate start of a new node! + if (!isSingle) indexOfLineBreak += 1; + + final indentDiff = currentOffset - indexOfLineBreak; + return currentOffset - indentDiff; +} + +/// 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, +}) { + final terminalNode = _findTerminalScalar(update); + + /// Nested function that 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 the + /// 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; + } + + /// 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(); +} + +/// Returns the terminal [YamlNode] that is a [YamlScalar]. +/// +/// If within a [YamlList], then the last value that is a [YamlScalar]. If +/// within a [YamlMap], then the last entry with a value that is a [YamlScalar]. +YamlScalar? _findTerminalScalar(YamlNode node) { + YamlNode? terminalNode = node; + + while (terminalNode is! YamlScalar) { + switch (terminalNode) { + case YamlList list: + { + if (list.isEmpty) return null; + terminalNode = list.nodes.last; + } + + case YamlMap map: + { + if (map.isEmpty) return null; + terminalNode = map.nodes.entries.last.value; + } + + default: + return null; + } + } + + return terminalNode; +} + extension YamlNodeExtension on YamlNode { /// Returns the [CollectionStyle] of `this` if `this` is [YamlMap] or /// [YamlList]. diff --git a/test/string_test.dart b/test/string_test.dart index b92c20a..8fb0fb5 100644 --- a/test/string_test.dart +++ b/test/string_test.dart @@ -6,6 +6,7 @@ import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; +// TODO: Add test for string with trailing space final _testStrings = [ "this is a fairly' long string with\nline breaks", 'whitespace\n after line breaks',