diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java index c3c1d6ca1f6..a2156906f44 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java @@ -22,6 +22,7 @@ import org.openrewrite.internal.ListUtils; import org.openrewrite.internal.NameCaseConvention; import org.openrewrite.internal.StringUtils; +import org.openrewrite.yaml.internal.BlockScalarUtils; import org.openrewrite.yaml.tree.Yaml; import java.util.Iterator; @@ -110,10 +111,14 @@ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionC private Yaml.@Nullable Block updateValue(Yaml.Block value) { if (value instanceof Yaml.Scalar) { Yaml.Scalar scalar = (Yaml.Scalar) value; - Yaml.Scalar newScalar = scalar.withValue(Boolean.TRUE.equals(regex) ? - scalar.getValue().replaceAll(Objects.requireNonNull(oldValue), newValue) : - newValue); - return scalar.getValue().equals(newScalar.getValue()) ? null : newScalar; + String body = BlockScalarUtils.getBody(scalar); + String updatedBody = Boolean.TRUE.equals(regex) ? + body.replaceAll(Objects.requireNonNull(oldValue), newValue) : + newValue; + if (body.equals(updatedBody)) { + return null; + } + return BlockScalarUtils.withBody(scalar, updatedBody); } if (value instanceof Yaml.Sequence) { Yaml.Sequence sequence = (Yaml.Sequence) value; diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java index 18aab31be58..ff4cfba8da7 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.marker.Markers; +import org.openrewrite.yaml.internal.BlockScalarUtils; import org.openrewrite.yaml.tree.Yaml; import static org.openrewrite.Tree.randomId; @@ -66,14 +67,18 @@ public TreeVisitor, ExecutionContext> getVisitor() { @Override public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); - if (matcher.matches(getCursor()) && (!(e.getValue() instanceof Yaml.Scalar) || !((Yaml.Scalar) e.getValue()).getValue().equals(value))) { - Yaml.Anchor anchor = (e.getValue() instanceof Yaml.Scalar) ? ((Yaml.Scalar) e.getValue()).getAnchor() : null; - Yaml.Tag tag = (e.getValue() instanceof Yaml.Scalar) ? ((Yaml.Scalar) e.getValue()).getTag() : null; - String prefix = e.getValue() instanceof Yaml.Sequence ? ((Yaml.Sequence) e.getValue()).getOpeningBracketPrefix() : e.getValue().getPrefix(); - e = e.withValue( - new Yaml.Scalar(randomId(), prefix, Markers.EMPTY, - Yaml.Scalar.Style.PLAIN, anchor, tag, value) - ); + if (matcher.matches(getCursor()) && (!(e.getValue() instanceof Yaml.Scalar) || !BlockScalarUtils.getBody((Yaml.Scalar) e.getValue()).equals(value))) { + if (e.getValue() instanceof Yaml.Scalar && isBlockStyle((Yaml.Scalar) e.getValue())) { + e = e.withValue(BlockScalarUtils.withBody((Yaml.Scalar) e.getValue(), value)); + } else { + Yaml.Anchor anchor = (e.getValue() instanceof Yaml.Scalar) ? ((Yaml.Scalar) e.getValue()).getAnchor() : null; + Yaml.Tag tag = (e.getValue() instanceof Yaml.Scalar) ? ((Yaml.Scalar) e.getValue()).getTag() : null; + String prefix = e.getValue() instanceof Yaml.Sequence ? ((Yaml.Sequence) e.getValue()).getOpeningBracketPrefix() : e.getValue().getPrefix(); + e = e.withValue( + new Yaml.Scalar(randomId(), prefix, Markers.EMPTY, + Yaml.Scalar.Style.PLAIN, anchor, tag, value) + ); + } } return e; } @@ -81,11 +86,16 @@ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionC @Override public Yaml.Scalar visitScalar(Yaml.Scalar scalar, ExecutionContext ctx) { Yaml.Scalar s = super.visitScalar(scalar, ctx); - if (matcher.matches(getCursor())) { - s = s.withValue(value); + if (matcher.matches(getCursor()) && !BlockScalarUtils.getBody(s).equals(value)) { + s = BlockScalarUtils.withBody(s, value); } return s; } + + private boolean isBlockStyle(Yaml.Scalar s) { + return s.getStyle() == Yaml.Scalar.Style.FOLDED || + s.getStyle() == Yaml.Scalar.Style.LITERAL; + } }); } diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java index 58664fcaa76..3c21273af79 100755 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java @@ -184,6 +184,11 @@ public Yaml.Mapping visitMapping(Yaml.Mapping mapping, ExecutionContext ctx) { entry = entry.withPrefix(firstDeletedPrefix); } else if (previousWasDeleted && !entries.isEmpty() && !containsNewline(entry.getPrefix())) { entry = entry.withPrefix("\n" + entry.getPrefix()); + } else if (previousWasDeleted && !entries.isEmpty() + && endsWithBlockScalar(entries.get(entries.size() - 1)) + && containsNewline(entry.getPrefix())) { + // Block-scalar predecessor already owns the boundary newline; strip the duplicate. + entry = entry.withPrefix(stripLeadingLineBreak(entry.getPrefix())); } entries.add(entry); previousWasDeleted = false; @@ -257,6 +262,32 @@ private static boolean containsOnlyWhitespace(@Nullable String str) { return true; } + private static String stripLeadingLineBreak(String s) { + if (s.startsWith("\r\n")) { + return s.substring(2); + } + if (s.startsWith("\n") || s.startsWith("\r")) { + return s.substring(1); + } + // Line break not at the start — strip the first one found. + int idx = -1; + int idxN = s.indexOf('\n'); + int idxR = s.indexOf('\r'); + if (idxN >= 0 && (idxR < 0 || idxN < idxR)) { + idx = idxN; + } else if (idxR >= 0) { + idx = idxR; + } + if (idx < 0) { + return s; + } + int after = idx + 1; + if (s.charAt(idx) == '\r' && after < s.length() && s.charAt(after) == '\n') { + after++; + } + return s.substring(0, idx) + s.substring(after); + } + private static boolean containsNewline(@Nullable String str) { return str != null && str.indexOf('\n') >= 0; } diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java new file mode 100644 index 00000000000..68d2abd562b --- /dev/null +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 the original author or authors. + *
+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.yaml.internal; + +import org.openrewrite.internal.StringUtils; +import org.openrewrite.yaml.tree.Yaml; + +/** + * Internal helpers for safely mutating FOLDED ({@code >}, {@code >-}, {@code >+}) and + * LITERAL ({@code |}, {@code |-}, {@code |+}) block scalars: the {@link Yaml.Scalar#value} + * field carries the block envelope (chomp indicator, indented body, trailing whitespace + * bounding the next sibling), so a naïve Lombok-generated {@code withValue} replacement + * corrupts the surrounding structure. + * + *
TODO: promote these onto {@link Yaml.Scalar} as instance {@code getBody} / + * {@code withBody} methods once enough downstream CLI bundles ship a {@code rewrite-yaml} + * including them — promoting now would {@code NoSuchMethodError} customer recipes that + * adopted the new API but load against an older bundled {@code Yaml.Scalar}. + */ +public final class BlockScalarUtils { + + private BlockScalarUtils() { + } + + /** + * Returns the body content of {@code scalar}, stripped of any style-specific envelope. + * For PLAIN and quoted styles this returns {@link Yaml.Scalar#value} verbatim. For block + * styles the body is dedented to column zero with interior line breaks normalized to + * {@code \n} (so callers can compare or regex against it irrespective of the file's + * CRLF/LF convention). + */ + public static String getBody(Yaml.Scalar scalar) { + if (!isBlockStyle(scalar)) { + return scalar.getValue(); + } + String value = scalar.getValue(); + int headerEnd = value.indexOf('\n'); + if (headerEnd < 0) { + return ""; + } + int bodyEnd = value.length(); + while (bodyEnd > headerEnd + 1 && Character.isWhitespace(value.charAt(bodyEnd - 1))) { + bodyEnd--; + } + if (bodyEnd <= headerEnd + 1) { + return ""; + } + String bodyRegion = value.substring(headerEnd + 1, bodyEnd); + int indent = 0; + while (indent < bodyRegion.length() && bodyRegion.charAt(indent) == ' ') { + indent++; + } + String indentStr = bodyRegion.substring(0, indent); + String[] lines = bodyRegion.split("\r\n|\r|\n", -1); + StringBuilder out = new StringBuilder(bodyRegion.length()); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (indent > 0 && line.startsWith(indentStr)) { + line = line.substring(indent); + } + if (i > 0) { + out.append('\n'); + } + out.append(line); + } + return out.toString(); + } + + /** {@link #withBody(Yaml.Scalar, String, int)} with a 2-space empty-body indent fallback. */ + public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody) { + return withBody(scalar, newBody, 2); + } + + /** + * Returns a copy of {@code scalar} with its body replaced by {@code newBody}. For PLAIN + * and quoted styles this just sets {@link Yaml.Scalar#value} (via the Lombok-generated + * {@code withValue}); for block styles the chomp indicator, header newline, body indent, + * and trailing whitespace are preserved, and each line of {@code newBody} is emitted in + * the existing value's line-ending convention. {@code defaultIndentSpaces} is used as the + * body indent width when the existing block scalar has an empty body — pass an + * {@code IndentsStyle#getIndentSize()} to honor the document's configured indent. + */ + public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody, int defaultIndentSpaces) { + if (!isBlockStyle(scalar)) { + return scalar.withValue(newBody); + } + String value = scalar.getValue(); + int headerEnd = value.indexOf('\n'); + String header = headerEnd < 0 ? value : value.substring(0, headerEnd + 1); + String newLine = (headerEnd > 0 && value.charAt(headerEnd - 1) == '\r') ? "\r\n" : "\n"; + int bodyEnd = value.length(); + while (bodyEnd > 0 && Character.isWhitespace(value.charAt(bodyEnd - 1))) { + bodyEnd--; + } + String indent; + if (headerEnd >= 0 && headerEnd + 1 < bodyEnd) { + int indentEnd = headerEnd + 1; + while (indentEnd < bodyEnd && value.charAt(indentEnd) == ' ') { + indentEnd++; + } + indent = value.substring(headerEnd + 1, indentEnd); + if (indent.isEmpty()) { + indent = StringUtils.repeat(" ", defaultIndentSpaces); + } + } else { + indent = StringUtils.repeat(" ", defaultIndentSpaces); + } + String trailing = value.substring(bodyEnd); + String[] lines = newBody.split("\r\n|\r|\n", -1); + StringBuilder body = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + body.append(newLine); + } + if (!lines[i].isEmpty()) { + body.append(indent).append(lines[i]); + } + } + return scalar.withValue(header + body + trailing); + } + + private static boolean isBlockStyle(Yaml.Scalar scalar) { + return scalar.getStyle() == Yaml.Scalar.Style.FOLDED || + scalar.getStyle() == Yaml.Scalar.Style.LITERAL; + } +} diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlReference.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlReference.java index 18ced348a74..d1de84c7a3f 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlReference.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlReference.java @@ -20,6 +20,7 @@ import org.openrewrite.SourceFile; import org.openrewrite.Tree; import org.openrewrite.trait.Reference; +import org.openrewrite.yaml.internal.BlockScalarUtils; import org.openrewrite.yaml.tree.Yaml; public abstract class YamlReference implements Reference { @@ -40,7 +41,7 @@ public boolean supportsRename() { public Tree rename(Renamer renamer, Cursor cursor, ExecutionContext ctx) { Tree tree = cursor.getValue(); if (tree instanceof Yaml.Scalar) { - return ((Yaml.Scalar) tree).withValue(renamer.rename(this)); + return BlockScalarUtils.withBody((Yaml.Scalar) tree, renamer.rename(this)); } throw new IllegalArgumentException("cursor.getValue() must be an Yaml.Scalar but is: " + tree.getClass()); } diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlValue.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlValue.java index 98fabf683d0..a4c7c65e38b 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/trait/YamlValue.java @@ -22,6 +22,7 @@ import org.openrewrite.trait.SimpleTraitMatcher; import org.openrewrite.trait.Trait; import org.openrewrite.yaml.JsonPathMatcher; +import org.openrewrite.yaml.internal.BlockScalarUtils; import org.openrewrite.yaml.tree.Yaml; @AllArgsConstructor @@ -52,7 +53,7 @@ public Yaml.Scalar getValueAsScalar() { } public YamlValue withValue(String newValue) { - Yaml.Scalar value = getValueAsScalar().withValue(newValue); + Yaml.Scalar value = BlockScalarUtils.withBody(getValueAsScalar(), newValue); cursor = new Cursor(cursor.getParent(), getTree().withValue(value)); return this; } diff --git a/rewrite-yaml/src/main/java/org/openrewrite/yaml/tree/Yaml.java b/rewrite-yaml/src/main/java/org/openrewrite/yaml/tree/Yaml.java index ec40f247451..f37663e7268 100755 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/tree/Yaml.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/tree/Yaml.java @@ -307,6 +307,11 @@ class Scalar implements Block, YamlKey { @Nullable Tag tag; + /** + * For FOLDED/LITERAL scalars this includes the chomp indicator, header newline, + * indented body, and trailing whitespace bounding the next sibling — so the + * Lombok-generated {@code withValue} cannot safely rewrite a block scalar's body. + */ String value; public enum Style { diff --git a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java index 35bbad20208..cd5805b3899 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java @@ -233,6 +233,207 @@ void supportYamlListValuesWithRegex() { ); } + @Test + void preservesFoldedClipBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: > + line one + line two + after: tail + """, + """ + key: > + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesFoldedStripBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: >- + line one + line two + after: tail + """, + """ + key: >- + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesFoldedKeepBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: >+ + line one + line two + + after: tail + """, + """ + key: >+ + replaced + + after: tail + """ + ) + ); + } + + @Test + void preservesLiteralClipBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: | + line one + line two + after: tail + """, + """ + key: | + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesLiteralStripBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: |- + line one + line two + after: tail + """, + """ + key: |- + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesLiteralKeepBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + """ + key: |+ + line one + line two + + after: tail + """, + """ + key: |+ + replaced + + after: tail + """ + ) + ); + } + + @Test + void multiLineNewValueReindentsAcrossBlockBody() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "first\nsecond", null, null, null, null)), + yaml( + """ + key: | + old line + after: tail + """, + """ + key: | + first + second + after: tail + """ + ) + ); + } + + @Test + void regexReplacementOnBlockScalarOperatesOnBodyOnly() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "B", "A", true, null, null)), + yaml( + """ + key: |- + line A one + line A two + after: tail + """, + """ + key: |- + line B one + line B two + after: tail + """ + ) + ); + } + + @Test + void preservesCrlfLiteralBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), + yaml( + "key: |\r\n" + + " line one\r\n" + + " line two\r\n" + + "after: tail\r\n", + "key: |\r\n" + + " replaced\r\n" + + "after: tail\r\n" + ) + ); + } + + @Test + void multilineReplacementOnCrlfBlockScalarKeepsCrlf() { + // The new value introduces its own interior line break (a bare '\n' from the recipe + // argument). On a CRLF file that break must be emitted as CRLF, not glued in as a lone + // LF that would leave the block with mixed line endings. + rewriteRun( + spec -> spec.recipe(new ChangePropertyValue("key", "new one\nnew two", null, null, null, null)), + yaml( + "key: |\r\n" + + " line one\r\n" + + " line two\r\n" + + "after: tail\r\n", + "key: |\r\n" + + " new one\r\n" + + " new two\r\n" + + "after: tail\r\n" + ) + ); + } + @Test void validatesThatOldValueIsRequiredIfRegexEnabled() { assertTrue(new ChangePropertyValue("my.prop", "bar", null, true, null, null).validate().isInvalid()); diff --git a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java index dd8321aad44..3456b86d854 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java @@ -179,6 +179,84 @@ void changeSequenceKeyByWildcard() { ); } + @Test + void preservesFoldedStripBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangeValue("$.key", "replaced", null)), + yaml( + """ + key: >- + line one + line two + after: tail + """, + """ + key: >- + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesLiteralKeepBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangeValue("$.key", "replaced", null)), + yaml( + """ + key: |+ + line one + line two + + after: tail + """, + """ + key: |+ + replaced + + after: tail + """ + ) + ); + } + + @Test + void preservesFoldedClipBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangeValue("$.key", "replaced", null)), + yaml( + """ + key: > + line one + line two + after: tail + """, + """ + key: > + replaced + after: tail + """ + ) + ); + } + + @Test + void preservesCrlfLiteralBlockEnvelope() { + rewriteRun( + spec -> spec.recipe(new ChangeValue("$.key", "replaced", null)), + yaml( + "key: |\r\n" + + " line one\r\n" + + " line two\r\n" + + "after: tail\r\n", + "key: |\r\n" + + " replaced\r\n" + + "after: tail\r\n" + ) + ); + } + @Test void changeSequenceKeyByExactMatch() { rewriteRun( diff --git a/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java b/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java index aea7551bba2..646e578ff5c 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java @@ -666,4 +666,90 @@ void deleteLastEntryPreservesInlineCommentOnPreviousEntry() { ) ); } + + @Test + void deleteEntryAfterFoldedBlockScalar() { + rewriteRun( + spec -> spec.recipe(new DeleteProperty("doomed", null, null, null)), + yaml( + """ + keep: >- + line one + line two + doomed: value + after: tail + """, + """ + keep: >- + line one + line two + after: tail + """ + ) + ); + } + + @Test + void deleteEntryAfterLiteralKeepBlockScalar() { + rewriteRun( + spec -> spec.recipe(new DeleteProperty("doomed", null, null, null)), + yaml( + """ + keep: |+ + line one + line two + + doomed: value + after: tail + """, + """ + keep: |+ + line one + line two + + after: tail + """ + ) + ); + } + + @Test + void deleteEntryBeforeBlockScalarIsUnchanged() { + rewriteRun( + spec -> spec.recipe(new DeleteProperty("doomed", null, null, null)), + yaml( + """ + doomed: value + keep: >- + line one + line two + after: tail + """, + """ + keep: >- + line one + line two + after: tail + """ + ) + ); + } + + @Test + void deleteEntryAfterCrlfBlockScalar() { + rewriteRun( + spec -> spec.recipe(new DeleteProperty("doomed", null, null, null)), + yaml( + "keep: >-\r\n" + + " line one\r\n" + + " line two\r\n" + + "doomed: value\r\n" + + "after: tail\r\n", + "keep: >-\r\n" + + " line one\r\n" + + " line two\r\n" + + "after: tail\r\n" + ) + ); + } } diff --git a/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java b/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java new file mode 100644 index 00000000000..bd69ef23876 --- /dev/null +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 the original author or authors. + *
+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.yaml.internal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.marker.Markers; +import org.openrewrite.yaml.tree.Yaml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.Tree.randomId; + +/** + * Tests {@code getBody}/{@code withBody} in isolation: a {@code \r} leaked by {@code getBody} + * can be re-absorbed by a subsequent {@code withBody} call and mask a line-ending bug in + * end-to-end recipe tests. + */ +class BlockScalarUtilsTest { + + private static Yaml.Scalar literal(String value) { + return new Yaml.Scalar(randomId(), "", Markers.EMPTY, Yaml.Scalar.Style.LITERAL, null, null, value); + } + + @Test + void getBodyStripsCrFromLfBody() { + Yaml.Scalar s = literal("\n line one\n line two\n"); + assertThat(BlockScalarUtils.getBody(s)).isEqualTo("line one\nline two"); + } + + @Test + void getBodyStripsCrFromCrlfBody() { + Yaml.Scalar s = literal("\r\n line one\r\n line two\r\n"); + assertThat(BlockScalarUtils.getBody(s)).isEqualTo("line one\nline two"); + } + + @Test + void withBodyKeepsLfForLfScalar() { + Yaml.Scalar s = literal("\n line one\n line two\n"); + Yaml.Scalar updated = BlockScalarUtils.withBody(s, "new one\nnew two"); + assertThat(updated.getValue()).isEqualTo("\n new one\n new two\n"); + } + + @Test + void withBodyEmitsCrlfForCrlfScalar() { + Yaml.Scalar s = literal("\r\n line one\r\n line two\r\n"); + Yaml.Scalar updated = BlockScalarUtils.withBody(s, "new one\nnew two"); + assertThat(updated.getValue()).isEqualTo("\r\n new one\r\n new two\r\n"); + } +}