From 9becb981f4307bcfa28adacb2117a273032d9346 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 30 Jun 2026 13:22:27 -0400 Subject: [PATCH 1/8] YAML: prevent block-scalar value mutations from corrupting siblings `Yaml.Scalar.value` carries different content depending on `style`: - For PLAIN and quoted scalars, `value` is just the body content. - For FOLDED (`>`, `>-`, `>+`) and LITERAL (`|`, `|-`, `|+`) scalars, `value` is everything after the `>` or `|` indicator: the chomp indicator (if any), the newline terminating the block-scalar header, the indented body, AND the trailing whitespace that bounds the block from the next sibling mapping entry. For block scalars, the boundary newline that visually separates the scalar from the next sibling lives inside the previous scalar's `value`, not in the next entry's `prefix`. A naive `scalar.withValue(...)` replacement clobbers the block envelope and lets the printer glue the next sibling key onto the same line. This affected several built-in recipes: `ChangePropertyValue`, `ChangeValue`, `DeleteProperty` (when a block scalar precedes the deleted entry), and the `YamlValue` / `YamlReference` traits. Adds an internal `BlockScalarUtils` helper with `getBody` / `withBody` that knows about the block envelope, refactors the affected recipes through it, and documents `Yaml.Scalar.value`'s per-style semantics. Helpers are intentionally kept internal for now to avoid promoting public API that downstream recipes could call into and then fail to load on older CLI bundles; promoting them onto `Yaml.Scalar` itself is left as a follow-up once adoption catches up. `MergeYaml` and `CoalesceProperties` exhibit related but distinct structural bugs that need more involved fixes; left for follow-up PRs. --- .../openrewrite/yaml/ChangePropertyValue.java | 16 +- .../org/openrewrite/yaml/ChangeValue.java | 34 ++-- .../org/openrewrite/yaml/DeleteProperty.java | 34 ++++ .../yaml/internal/BlockScalarUtils.java | 164 +++++++++++++++++ .../openrewrite/yaml/trait/YamlReference.java | 3 +- .../org/openrewrite/yaml/trait/YamlValue.java | 3 +- .../java/org/openrewrite/yaml/tree/Yaml.java | 20 +++ .../yaml/ChangePropertyValueTest.java | 165 ++++++++++++++++++ .../org/openrewrite/yaml/ChangeValueTest.java | 62 +++++++ .../yaml/DeletePropertyKeyTest.java | 68 ++++++++ 10 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java 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..40fd3ef71ad 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,17 @@ 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; + // Operate on the style-aware body rather than the raw `value` field, so that + // FOLDED/LITERAL block scalars preserve their envelope (chomp indicator, header + // newline, trailing whitespace that bounds the block from the next sibling). + 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..d9e17d83670 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,22 @@ public TreeVisitor 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())) { + // Preserve the user's chosen FOLDED/LITERAL style and its envelope + // (chomp indicator, header newline, trailing whitespace bounding the + // block from the next sibling) — replacing with a fresh PLAIN scalar + // would orphan the boundary newline and glue siblings together. + 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 +90,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..8befb2efefd 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,14 @@ 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 ended in a trailing newline (it lives + // inside its `value` slot). With the intervening entry gone, the next + // entry's prefix newline becomes redundant — strip it to avoid a blank + // line. + entry = entry.withPrefix(stripLeadingLineBreak(entry.getPrefix())); } entries.add(entry); previousWasDeleted = false; @@ -257,6 +265,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..32fc487885b --- /dev/null +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java @@ -0,0 +1,164 @@ +/* + * 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 in the YAML LST. + * + *

The trap this exists to avoid: for block scalars, {@link Yaml.Scalar#getValue()} + * carries three concatenated things — the chomp/indent indicator(s), the indented body, + * AND the trailing whitespace that bounds the block from the next sibling mapping entry. + * Naïvely replacing {@code value} via {@link Yaml.Scalar#withValue(String)} clobbers the + * block envelope, drops the chomp indicator, and lets the printer glue the next sibling + * key onto the same line. + * + *

Use {@link #getBody(Yaml.Scalar)} and {@link #withBody(Yaml.Scalar, String)} when a + * recipe needs to set or transform a property's value and the existing scalar might be a + * block scalar. + * + *

TODO: these helpers should eventually move onto {@link Yaml.Scalar} itself as + * instance methods ({@code getBody()}, {@code withBody(String)}) so they're discoverable + * to recipe authors. Promoting them is deliberately deferred to avoid a forwards-compatibility + * trap: built-in and customer recipes compiled against new {@code Yaml.Scalar} methods would + * {@code NoSuchMethodError} when loaded under Moderne CLI versions that bundle an older + * {@code rewrite-yaml}. Once a long-enough adoption window has passed for those CLI bundles + * to roll forward, inline this logic into {@code Yaml.Scalar} and have consumers call the + * instance methods directly. + */ +public final class BlockScalarUtils { + + private BlockScalarUtils() { + } + + /** + * Returns the body content of {@code scalar}, stripped of any style-specific envelope. + * + *

For PLAIN and quoted scalars this is identical to {@link Yaml.Scalar#getValue()}. + * For FOLDED and LITERAL scalars this is the body dedented to column zero, joined with + * single {@code \n}s, with the chomp indicator, header newline, indent, and trailing + * whitespace stripped. + */ + 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++; + } + if (indent == 0) { + return bodyRegion; + } + String indentStr = bodyRegion.substring(0, indent); + String[] lines = bodyRegion.split("\n", -1); + StringBuilder out = new StringBuilder(bodyRegion.length()); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.startsWith(indentStr)) { + line = line.substring(indent); + } + if (i > 0) { + out.append('\n'); + } + out.append(line); + } + return out.toString(); + } + + /** + * Returns a copy of {@code scalar} with its body replaced by {@code newBody}, defaulting + * the empty-body indent fallback to 2 spaces. + */ + 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 is equivalent to {@link Yaml.Scalar#withValue(String)}. + * For FOLDED and LITERAL scalars the block envelope is preserved: the chomp indicator, + * header newline, and trailing whitespace that bounds the block from the next sibling are + * kept intact, and each line of {@code newBody} is re-indented to the block body's column. + * + *

If the original block scalar had an empty body and the body indent cannot be + * recovered from the existing value, {@code defaultIndentSpaces} is used as the indent + * width. Recipes that honor the document's configured {@code IndentsStyle} should pass + * its {@code getIndentSize()}; {@code 2} matches the YAML convention default. + */ + 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); + 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); + if (trailing.isEmpty()) { + trailing = "\n"; + } + String[] lines = newBody.split("\n", -1); + StringBuilder body = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + body.append('\n'); + } + 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..77fa360febb 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,26 @@ class Scalar implements Block, YamlKey { @Nullable Tag tag; + /** + * The raw scalar content as it appears in source, prior to any spec-level + * resolution (escape sequences, folding, chomping). The semantics depend on + * {@link #style}: + * + *

+ */ 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..9261444f902 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,171 @@ 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 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..b93d708ab2b 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,68 @@ 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 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..ff4597bb921 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,72 @@ 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 + """ + ) + ); + } } From bc78df85e69b3781ffb70f1bda4cfeb792ca098c Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 30 Jun 2026 14:25:20 -0400 Subject: [PATCH 2/8] YAML: keep block-scalar line endings on CRLF files `BlockScalarUtils.getBody` split the body on `\n` only, leaving stray `\r`s in the returned body on CRLF-authored files, and `withBody` always joined interior lines with bare `\n`, producing mixed `\r\n`/`\n` block scalars when editing on Windows. `getBody` now splits on any line-ending form and rejoins with `\n` for a platform-neutral body; `withBody` detects the existing value's line-ending convention from the block header and uses it for interior breaks and the empty-trailing fallback. Adds a focused BlockScalarUtilsTest that exercises each helper in isolation: the recipe path calls getBody then withBody, where a `\r` leaked by getBody is re-absorbed by withBody, masking the bug end-to-end. Also adds CRLF cases to ChangePropertyValueTest, ChangeValueTest, and DeletePropertyKeyTest. --- .../yaml/internal/BlockScalarUtils.java | 27 +++++--- .../yaml/ChangePropertyValueTest.java | 36 ++++++++++ .../org/openrewrite/yaml/ChangeValueTest.java | 16 +++++ .../yaml/DeletePropertyKeyTest.java | 18 +++++ .../yaml/internal/BlockScalarUtilsTest.java | 66 +++++++++++++++++++ 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java 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 index 32fc487885b..f8058763886 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java @@ -54,6 +54,12 @@ private BlockScalarUtils() { * For FOLDED and LITERAL scalars this is the body dedented to column zero, joined with * single {@code \n}s, with the chomp indicator, header newline, indent, and trailing * whitespace stripped. + * + *

The returned body is line-ending neutral: regardless of whether the source file uses + * {@code \n} or {@code \r\n}, interior line breaks are normalized to {@code \n} so that + * callers can compare and regex-match against it without worrying about the platform the + * file was authored on. Use {@link #withBody(Yaml.Scalar, String)} to write a body back in + * the document's own line-ending convention. */ public static String getBody(Yaml.Scalar scalar) { if (!isBlockStyle(scalar)) { @@ -76,15 +82,14 @@ public static String getBody(Yaml.Scalar scalar) { while (indent < bodyRegion.length() && bodyRegion.charAt(indent) == ' ') { indent++; } - if (indent == 0) { - return bodyRegion; - } String indentStr = bodyRegion.substring(0, indent); - String[] lines = bodyRegion.split("\n", -1); + // Split on any line-ending form so a CRLF-authored file does not leave stray '\r's in + // the body, then rejoin with '\n' for a platform-neutral result. + 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 (line.startsWith(indentStr)) { + if (indent > 0 && line.startsWith(indentStr)) { line = line.substring(indent); } if (i > 0) { @@ -110,6 +115,9 @@ public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody) { * For FOLDED and LITERAL scalars the block envelope is preserved: the chomp indicator, * header newline, and trailing whitespace that bounds the block from the next sibling are * kept intact, and each line of {@code newBody} is re-indented to the block body's column. + * Interior line breaks in {@code newBody} are emitted in the existing value's line-ending + * convention ({@code \r\n} if the block header uses CRLF, otherwise {@code \n}), so that + * editing a body on a Windows-authored file does not introduce mixed line endings. * *

If the original block scalar had an empty body and the body indent cannot be * recovered from the existing value, {@code defaultIndentSpaces} is used as the indent @@ -123,6 +131,9 @@ public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody, int defau String value = scalar.getValue(); int headerEnd = value.indexOf('\n'); String header = headerEnd < 0 ? value : value.substring(0, headerEnd + 1); + // Match the document's line-ending convention for interior breaks: if the block header + // terminates with CRLF, keep emitting CRLF rather than gluing in bare '\n's. + 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--; @@ -142,13 +153,13 @@ public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody, int defau } String trailing = value.substring(bodyEnd); if (trailing.isEmpty()) { - trailing = "\n"; + trailing = newLine; } - String[] lines = newBody.split("\n", -1); + 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('\n'); + body.append(newLine); } if (!lines[i].isEmpty()) { body.append(indent).append(lines[i]); 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 9261444f902..939a2fa8d57 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java @@ -398,6 +398,42 @@ void regexReplacementOnBlockScalarOperatesOnBodyOnly() { ); } + @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 b93d708ab2b..3456b86d854 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangeValueTest.java @@ -241,6 +241,22 @@ void preservesFoldedClipBlockEnvelope() { ); } + @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 ff4597bb921..646e578ff5c 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/DeletePropertyKeyTest.java @@ -734,4 +734,22 @@ void deleteEntryBeforeBlockScalarIsUnchanged() { ) ); } + + @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..31c04b2dc3b --- /dev/null +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java @@ -0,0 +1,66 @@ +/* + * 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; + +/** + * Direct unit tests for {@link BlockScalarUtils}, isolating {@code getBody}/{@code withBody} + * from the recipe round-trip. The recipe path calls {@code getBody} then {@code withBody}, so a + * stray {@code \r} leaked by {@code getBody} can be re-absorbed by {@code withBody} and mask a + * line-ending bug end-to-end; testing each method in isolation prevents that masking. + */ +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() { + // header "\n", two indented lines, trailing "\n" + Yaml.Scalar s = literal("\n line one\n line two\n"); + assertThat(BlockScalarUtils.getBody(s)).isEqualTo("line one\nline two"); + } + + @Test + void getBodyStripsCrFromCrlfBody() { + // A CRLF-authored block scalar must not leak '\r' into the returned body. + 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() { + // Interior breaks of the new body must follow the existing CRLF convention, and the + // preserved header/trailing must stay CRLF as well — no mixed endings. + 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"); + } +} From fd1bf85af9a1df27293c49a9800ef26f6e8e71ad Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 30 Jun 2026 15:10:13 -0400 Subject: [PATCH 3/8] YAML: trim block-scalar fix comments and normalize test indentation - Drop explanatory inline comments in ChangePropertyValue/ChangeValue where the call to BlockScalarUtils tells the story. - Compress DeleteProperty's prefix-strip comment to one line. - Tighten the Javadoc on Yaml.Scalar.value, BlockScalarUtils, and BlockScalarUtilsTest; drop tangential prose. - Stop linking to Lombok-generated method signatures (withValue, getValue); reference the underlying field instead, with a note about the generated accessor. - Reformat the new block-scalar tests in ChangePropertyValueTest to match the cleaner indentation used in ChangeValueTest / DeletePropertyKeyTest. --- .../openrewrite/yaml/ChangePropertyValue.java | 3 - .../org/openrewrite/yaml/ChangeValue.java | 4 - .../org/openrewrite/yaml/DeleteProperty.java | 5 +- .../yaml/internal/BlockScalarUtils.java | 73 +++----- .../java/org/openrewrite/yaml/tree/Yaml.java | 25 +-- .../yaml/ChangePropertyValueTest.java | 166 +++++++++--------- .../yaml/internal/BlockScalarUtilsTest.java | 11 +- 7 files changed, 114 insertions(+), 173 deletions(-) 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 40fd3ef71ad..a2156906f44 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java @@ -111,9 +111,6 @@ 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; - // Operate on the style-aware body rather than the raw `value` field, so that - // FOLDED/LITERAL block scalars preserve their envelope (chomp indicator, header - // newline, trailing whitespace that bounds the block from the next sibling). String body = BlockScalarUtils.getBody(scalar); String updatedBody = Boolean.TRUE.equals(regex) ? body.replaceAll(Objects.requireNonNull(oldValue), newValue) : 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 d9e17d83670..ff4cfba8da7 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java @@ -69,10 +69,6 @@ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionC Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); 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())) { - // Preserve the user's chosen FOLDED/LITERAL style and its envelope - // (chomp indicator, header newline, trailing whitespace bounding the - // block from the next sibling) — replacing with a fresh PLAIN scalar - // would orphan the boundary newline and glue siblings together. 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; 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 8befb2efefd..3c21273af79 100755 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/DeleteProperty.java @@ -187,10 +187,7 @@ public Yaml.Mapping visitMapping(Yaml.Mapping mapping, ExecutionContext ctx) { } else if (previousWasDeleted && !entries.isEmpty() && endsWithBlockScalar(entries.get(entries.size() - 1)) && containsNewline(entry.getPrefix())) { - // Block-scalar predecessor already ended in a trailing newline (it lives - // inside its `value` slot). With the intervening entry gone, the next - // entry's prefix newline becomes redundant — strip it to avoid a blank - // line. + // Block-scalar predecessor already owns the boundary newline; strip the duplicate. entry = entry.withPrefix(stripLeadingLineBreak(entry.getPrefix())); } entries.add(entry); 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 index f8058763886..f1ca6c8ecd2 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java @@ -20,27 +20,15 @@ /** * Internal helpers for safely mutating FOLDED ({@code >}, {@code >-}, {@code >+}) and - * LITERAL ({@code |}, {@code |-}, {@code |+}) block scalars in the YAML LST. + * 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. * - *

The trap this exists to avoid: for block scalars, {@link Yaml.Scalar#getValue()} - * carries three concatenated things — the chomp/indent indicator(s), the indented body, - * AND the trailing whitespace that bounds the block from the next sibling mapping entry. - * Naïvely replacing {@code value} via {@link Yaml.Scalar#withValue(String)} clobbers the - * block envelope, drops the chomp indicator, and lets the printer glue the next sibling - * key onto the same line. - * - *

Use {@link #getBody(Yaml.Scalar)} and {@link #withBody(Yaml.Scalar, String)} when a - * recipe needs to set or transform a property's value and the existing scalar might be a - * block scalar. - * - *

TODO: these helpers should eventually move onto {@link Yaml.Scalar} itself as - * instance methods ({@code getBody()}, {@code withBody(String)}) so they're discoverable - * to recipe authors. Promoting them is deliberately deferred to avoid a forwards-compatibility - * trap: built-in and customer recipes compiled against new {@code Yaml.Scalar} methods would - * {@code NoSuchMethodError} when loaded under Moderne CLI versions that bundle an older - * {@code rewrite-yaml}. Once a long-enough adoption window has passed for those CLI bundles - * to roll forward, inline this logic into {@code Yaml.Scalar} and have consumers call the - * instance methods directly. + *

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 { @@ -49,17 +37,10 @@ private BlockScalarUtils() { /** * Returns the body content of {@code scalar}, stripped of any style-specific envelope. - * - *

For PLAIN and quoted scalars this is identical to {@link Yaml.Scalar#getValue()}. - * For FOLDED and LITERAL scalars this is the body dedented to column zero, joined with - * single {@code \n}s, with the chomp indicator, header newline, indent, and trailing - * whitespace stripped. - * - *

The returned body is line-ending neutral: regardless of whether the source file uses - * {@code \n} or {@code \r\n}, interior line breaks are normalized to {@code \n} so that - * callers can compare and regex-match against it without worrying about the platform the - * file was authored on. Use {@link #withBody(Yaml.Scalar, String)} to write a body back in - * the document's own line-ending convention. + * 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)) { @@ -83,8 +64,6 @@ public static String getBody(Yaml.Scalar scalar) { indent++; } String indentStr = bodyRegion.substring(0, indent); - // Split on any line-ending form so a CRLF-authored file does not leave stray '\r's in - // the body, then rejoin with '\n' for a platform-neutral result. String[] lines = bodyRegion.split("\r\n|\r|\n", -1); StringBuilder out = new StringBuilder(bodyRegion.length()); for (int i = 0; i < lines.length; i++) { @@ -100,29 +79,19 @@ public static String getBody(Yaml.Scalar scalar) { return out.toString(); } - /** - * Returns a copy of {@code scalar} with its body replaced by {@code newBody}, defaulting - * the empty-body indent fallback to 2 spaces. - */ + /** {@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 is equivalent to {@link Yaml.Scalar#withValue(String)}. - * For FOLDED and LITERAL scalars the block envelope is preserved: the chomp indicator, - * header newline, and trailing whitespace that bounds the block from the next sibling are - * kept intact, and each line of {@code newBody} is re-indented to the block body's column. - * Interior line breaks in {@code newBody} are emitted in the existing value's line-ending - * convention ({@code \r\n} if the block header uses CRLF, otherwise {@code \n}), so that - * editing a body on a Windows-authored file does not introduce mixed line endings. - * - *

If the original block scalar had an empty body and the body indent cannot be - * recovered from the existing value, {@code defaultIndentSpaces} is used as the indent - * width. Recipes that honor the document's configured {@code IndentsStyle} should pass - * its {@code getIndentSize()}; {@code 2} matches the YAML convention default. + * 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)) { @@ -131,8 +100,6 @@ public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody, int defau String value = scalar.getValue(); int headerEnd = value.indexOf('\n'); String header = headerEnd < 0 ? value : value.substring(0, headerEnd + 1); - // Match the document's line-ending convention for interior breaks: if the block header - // terminates with CRLF, keep emitting CRLF rather than gluing in bare '\n's. 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))) { 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 77fa360febb..11e60f88990 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 @@ -308,24 +308,13 @@ class Scalar implements Block, YamlKey { Tag tag; /** - * The raw scalar content as it appears in source, prior to any spec-level - * resolution (escape sequences, folding, chomping). The semantics depend on - * {@link #style}: - * - *

+ * Raw source content; semantics depend on {@link #style}. For PLAIN, SINGLE_QUOTED, + * and DOUBLE_QUOTED scalars this is the body as written (double-quoted escape + * sequences are stored verbatim, not resolved). For FOLDED and LITERAL scalars this + * is everything after the {@code >} or {@code |} indicator — chomp/indent indicators, + * the header newline, the indented body, AND the trailing whitespace bounding the + * block from the next sibling — so the Lombok-generated {@code withValue} cannot be + * used safely to set a new scalar value; use {@code BlockScalarUtils} instead. */ String value; 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 939a2fa8d57..cd5805b3899 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/ChangePropertyValueTest.java @@ -238,17 +238,17 @@ 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 """ + key: > + line one + line two + after: tail + """, + """ + key: > + replaced + after: tail + """ ) ); } @@ -258,17 +258,17 @@ 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 """ + key: >- + line one + line two + after: tail + """, + """ + key: >- + replaced + after: tail + """ ) ); } @@ -278,19 +278,19 @@ void preservesFoldedKeepBlockEnvelope() { rewriteRun( spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), yaml( - """ - key: >+ - line one - line two - - after: tail - """, - """ - key: >+ - replaced + """ + key: >+ + line one + line two - after: tail + after: tail + """, """ + key: >+ + replaced + + after: tail + """ ) ); } @@ -300,17 +300,17 @@ 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 """ + key: | + line one + line two + after: tail + """, + """ + key: | + replaced + after: tail + """ ) ); } @@ -320,17 +320,17 @@ 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 """ + key: |- + line one + line two + after: tail + """, + """ + key: |- + replaced + after: tail + """ ) ); } @@ -340,19 +340,19 @@ void preservesLiteralKeepBlockEnvelope() { rewriteRun( spec -> spec.recipe(new ChangePropertyValue("key", "replaced", null, null, null, null)), yaml( - """ - key: |+ - line one - line two - - after: tail - """, - """ - key: |+ - replaced + """ + key: |+ + line one + line two - after: tail + after: tail + """, """ + key: |+ + replaced + + after: tail + """ ) ); } @@ -362,17 +362,17 @@ 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 """ + key: | + old line + after: tail + """, + """ + key: | + first + second + after: tail + """ ) ); } @@ -382,18 +382,18 @@ 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 """ + key: |- + line A one + line A two + after: tail + """, + """ + key: |- + line B one + line B two + after: tail + """ ) ); } 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 index 31c04b2dc3b..bd69ef23876 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java @@ -23,10 +23,9 @@ import static org.openrewrite.Tree.randomId; /** - * Direct unit tests for {@link BlockScalarUtils}, isolating {@code getBody}/{@code withBody} - * from the recipe round-trip. The recipe path calls {@code getBody} then {@code withBody}, so a - * stray {@code \r} leaked by {@code getBody} can be re-absorbed by {@code withBody} and mask a - * line-ending bug end-to-end; testing each method in isolation prevents that masking. + * 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 { @@ -36,14 +35,12 @@ private static Yaml.Scalar literal(String value) { @Test void getBodyStripsCrFromLfBody() { - // header "\n", two indented lines, trailing "\n" Yaml.Scalar s = literal("\n line one\n line two\n"); assertThat(BlockScalarUtils.getBody(s)).isEqualTo("line one\nline two"); } @Test void getBodyStripsCrFromCrlfBody() { - // A CRLF-authored block scalar must not leak '\r' into the returned body. Yaml.Scalar s = literal("\r\n line one\r\n line two\r\n"); assertThat(BlockScalarUtils.getBody(s)).isEqualTo("line one\nline two"); } @@ -57,8 +54,6 @@ void withBodyKeepsLfForLfScalar() { @Test void withBodyEmitsCrlfForCrlfScalar() { - // Interior breaks of the new body must follow the existing CRLF convention, and the - // preserved header/trailing must stay CRLF as well — no mixed endings. 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"); From 4ed2190ce34549a3e781f306b179e19f23f9bf51 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 30 Jun 2026 15:45:29 -0400 Subject: [PATCH 4/8] YAML: trim Yaml.Scalar.value javadoc per PR feedback --- .../src/main/java/org/openrewrite/yaml/tree/Yaml.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 11e60f88990..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 @@ -308,13 +308,9 @@ class Scalar implements Block, YamlKey { Tag tag; /** - * Raw source content; semantics depend on {@link #style}. For PLAIN, SINGLE_QUOTED, - * and DOUBLE_QUOTED scalars this is the body as written (double-quoted escape - * sequences are stored verbatim, not resolved). For FOLDED and LITERAL scalars this - * is everything after the {@code >} or {@code |} indicator — chomp/indent indicators, - * the header newline, the indented body, AND the trailing whitespace bounding the - * block from the next sibling — so the Lombok-generated {@code withValue} cannot be - * used safely to set a new scalar value; use {@code BlockScalarUtils} instead. + * 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; From 92c733378e69f8eb43217678259719860dbdab3c Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Tue, 30 Jun 2026 15:57:09 -0400 Subject: [PATCH 5/8] YAML: drop BlockScalarUtils.withBody trailing-newline fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback added a '\n' whenever the existing scalar's value had no trailing whitespace. For block scalars parsed directly from source the value always carries trailing whitespace, so the fallback never fires in the consumers shipped in this PR. For scalars produced upstream by autoFormat (used by the merge-family recipes touched in the follow-up), the boundary newline lives in the next entry's prefix and the existing value legitimately has no trailing whitespace — adding one there introduces an extra blank line between the scalar and its successor. Preserve the existing trailing whitespace exactly. No behavioral change for any consumer in this PR; surfaces the bug to be fixed in the MergeYaml follow-up where it actually matters. --- .../java/org/openrewrite/yaml/internal/BlockScalarUtils.java | 3 --- 1 file changed, 3 deletions(-) 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 index f1ca6c8ecd2..68d2abd562b 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java @@ -119,9 +119,6 @@ public static Yaml.Scalar withBody(Yaml.Scalar scalar, String newBody, int defau indent = StringUtils.repeat(" ", defaultIndentSpaces); } String trailing = value.substring(bodyEnd); - if (trailing.isEmpty()) { - trailing = newLine; - } String[] lines = newBody.split("\r\n|\r|\n", -1); StringBuilder body = new StringBuilder(); for (int i = 0; i < lines.length; i++) { From 9b36514e3628b8253f85d8e643ae5b1d3215f3e2 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Thu, 2 Jul 2026 10:26:39 -0400 Subject: [PATCH 6/8] YAML: promote BlockScalarUtils helpers onto Yaml.Scalar Move getBody() and withBody(String) / withBody(String, int) from the internal BlockScalarUtils onto Yaml.Scalar itself as instance methods, per review discussion. The internal utility class and its dedicated test are deleted; the four CRLF-boundary unit tests move to ScalarTest under a @Nested BlockScalarBody block. Consumers updated to call scalar.getBody() / scalar.withBody(...) instead of the static helpers: ChangePropertyValue, ChangeValue, YamlValue, YamlReference. No behavior change in any consumer. The Javadoc pointer on Yaml.Scalar.value now references withBody(String) directly instead of the internal utility. --- .../openrewrite/yaml/ChangePropertyValue.java | 5 +- .../org/openrewrite/yaml/ChangeValue.java | 9 +- .../yaml/internal/BlockScalarUtils.java | 139 ------------------ .../openrewrite/yaml/trait/YamlReference.java | 3 +- .../org/openrewrite/yaml/trait/YamlValue.java | 3 +- .../java/org/openrewrite/yaml/tree/Yaml.java | 102 +++++++++++++ .../yaml/internal/BlockScalarUtilsTest.java | 61 -------- .../org/openrewrite/yaml/tree/ScalarTest.java | 35 +++++ 8 files changed, 145 insertions(+), 212 deletions(-) delete mode 100644 rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java delete mode 100644 rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java 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 a2156906f44..f41249d2f21 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangePropertyValue.java @@ -22,7 +22,6 @@ 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; @@ -111,14 +110,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; - String body = BlockScalarUtils.getBody(scalar); + String body = scalar.getBody(); String updatedBody = Boolean.TRUE.equals(regex) ? body.replaceAll(Objects.requireNonNull(oldValue), newValue) : newValue; if (body.equals(updatedBody)) { return null; } - return BlockScalarUtils.withBody(scalar, updatedBody); + return scalar.withBody(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 ff4cfba8da7..1c6a92ae764 100644 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java +++ b/rewrite-yaml/src/main/java/org/openrewrite/yaml/ChangeValue.java @@ -20,7 +20,6 @@ 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; @@ -67,9 +66,9 @@ public TreeVisitor 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) || !BlockScalarUtils.getBody((Yaml.Scalar) e.getValue()).equals(value))) { + if (matcher.matches(getCursor()) && (!(e.getValue() instanceof Yaml.Scalar) || !((Yaml.Scalar) e.getValue()).getBody().equals(value))) { if (e.getValue() instanceof Yaml.Scalar && isBlockStyle((Yaml.Scalar) e.getValue())) { - e = e.withValue(BlockScalarUtils.withBody((Yaml.Scalar) e.getValue(), value)); + e = e.withValue(((Yaml.Scalar) e.getValue()).withBody(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; @@ -86,8 +85,8 @@ 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()) && !BlockScalarUtils.getBody(s).equals(value)) { - s = BlockScalarUtils.withBody(s, value); + if (matcher.matches(getCursor()) && !s.getBody().equals(value)) { + s = s.withBody(value); } return s; } 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 deleted file mode 100644 index 68d2abd562b..00000000000 --- a/rewrite-yaml/src/main/java/org/openrewrite/yaml/internal/BlockScalarUtils.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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 d1de84c7a3f..bc432e7aae0 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,7 +20,6 @@ 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 { @@ -41,7 +40,7 @@ public boolean supportsRename() { public Tree rename(Renamer renamer, Cursor cursor, ExecutionContext ctx) { Tree tree = cursor.getValue(); if (tree instanceof Yaml.Scalar) { - return BlockScalarUtils.withBody((Yaml.Scalar) tree, renamer.rename(this)); + return ((Yaml.Scalar) tree).withBody(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 a4c7c65e38b..d9aa5419da7 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,7 +22,6 @@ 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 @@ -53,7 +52,7 @@ public Yaml.Scalar getValueAsScalar() { } public YamlValue withValue(String newValue) { - Yaml.Scalar value = BlockScalarUtils.withBody(getValueAsScalar(), newValue); + Yaml.Scalar value = getValueAsScalar().withBody(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 f37663e7268..086e6cc4672 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 @@ -22,6 +22,7 @@ import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.internal.CommentService; +import org.openrewrite.internal.StringUtils; import org.openrewrite.marker.Markers; import org.openrewrite.yaml.YamlVisitor; import org.openrewrite.yaml.internal.YamlPrinter; @@ -311,6 +312,8 @@ class Scalar implements Block, YamlKey { * 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. + * Use {@link #getBody()} / {@link #withBody(String)} to mutate the body without + * clobbering the block envelope. */ String value; @@ -322,6 +325,105 @@ public enum Style { PLAIN } + /** + * Returns the body content of this scalar, stripped of any style-specific envelope. + * For PLAIN and quoted styles this returns {@link #value} verbatim. For FOLDED and + * LITERAL 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 String getBody() { + if (!isBlockStyle()) { + return value; + } + 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(String, int)} with a 2-space empty-body indent fallback. */ + public Scalar withBody(String newBody) { + return withBody(newBody, 2); + } + + /** + * Returns a copy of this scalar with its body replaced by {@code newBody}. For PLAIN + * and quoted styles this just sets {@link #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 Scalar withBody(String newBody, int defaultIndentSpaces) { + if (!isBlockStyle()) { + return withValue(newBody); + } + 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 withValue(header + body + trailing); + } + + private boolean isBlockStyle() { + return style == Style.FOLDED || style == Style.LITERAL; + } + @Override public

Yaml acceptYaml(YamlVisitor

v, P p) { return v.visitScalar(this, p); 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 deleted file mode 100644 index bd69ef23876..00000000000 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/internal/BlockScalarUtilsTest.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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"); - } -} diff --git a/rewrite-yaml/src/test/java/org/openrewrite/yaml/tree/ScalarTest.java b/rewrite-yaml/src/test/java/org/openrewrite/yaml/tree/ScalarTest.java index 70ff201e44c..d194a5ade72 100644 --- a/rewrite-yaml/src/test/java/org/openrewrite/yaml/tree/ScalarTest.java +++ b/rewrite-yaml/src/test/java/org/openrewrite/yaml/tree/ScalarTest.java @@ -16,12 +16,15 @@ package org.openrewrite.yaml.tree; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.openrewrite.Issue; +import org.openrewrite.marker.Markers; import org.openrewrite.test.RewriteTest; import org.openrewrite.yaml.YamlIsoVisitor; import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.Tree.randomId; import static org.openrewrite.yaml.Assertions.yaml; class ScalarTest implements RewriteTest { @@ -78,4 +81,36 @@ void loneScalar() { })) ); } + + @Nested + class BlockScalarBody { + + private 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(s.getBody()).isEqualTo("line one\nline two"); + } + + @Test + void getBodyStripsCrFromCrlfBody() { + Yaml.Scalar s = literal("\r\n line one\r\n line two\r\n"); + assertThat(s.getBody()).isEqualTo("line one\nline two"); + } + + @Test + void withBodyKeepsLfForLfScalar() { + Yaml.Scalar s = literal("\n line one\n line two\n"); + assertThat(s.withBody("new one\nnew two").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"); + assertThat(s.withBody("new one\nnew two").getValue()).isEqualTo("\r\n new one\r\n new two\r\n"); + } + } } From 234e4a82e3da0bfdd721595d873fbb92e4433981 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Thu, 2 Jul 2026 10:30:09 -0400 Subject: [PATCH 7/8] YAML: trim javadoc on Yaml.Scalar body helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact the docstrings added in 9b36514e3 — drop the "Lombok-generated withValue" reference (readable from the class annotations), collapse the CRLF-normalization explanation to one line, and tighten the withBody overload docs. --- .../java/org/openrewrite/yaml/tree/Yaml.java | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) 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 086e6cc4672..489ba64e760 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 @@ -310,10 +310,9 @@ class Scalar implements Block, YamlKey { /** * 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. - * Use {@link #getBody()} / {@link #withBody(String)} to mutate the body without - * clobbering the block envelope. + * indented body, and trailing whitespace bounding the next sibling; use + * {@link #getBody()} / {@link #withBody(String)} to mutate without clobbering the + * block envelope. */ String value; @@ -326,11 +325,9 @@ public enum Style { } /** - * Returns the body content of this scalar, stripped of any style-specific envelope. - * For PLAIN and quoted styles this returns {@link #value} verbatim. For FOLDED and - * LITERAL 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). + * Returns {@link #value} for PLAIN and quoted styles. For FOLDED / LITERAL, returns + * the body dedented to column zero with interior line breaks normalized to {@code \n} + * regardless of the source file's line-ending convention. */ public String getBody() { if (!isBlockStyle()) { @@ -374,13 +371,11 @@ public Scalar withBody(String newBody) { } /** - * Returns a copy of this scalar with its body replaced by {@code newBody}. For PLAIN - * and quoted styles this just sets {@link #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. + * Returns a copy with the body replaced. For PLAIN and quoted styles, equivalent to + * {@code withValue(newBody)}. For FOLDED / LITERAL, the block envelope and existing + * line-ending convention are preserved; {@code defaultIndentSpaces} is the body indent + * width when the source block scalar had an empty body (pass + * {@code IndentsStyle.getIndentSize()} to honor the document's configured indent). */ public Scalar withBody(String newBody, int defaultIndentSpaces) { if (!isBlockStyle()) { From 5ce3f24fe695c8aa3727b9e75a0e2cf4bf4cec21 Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Thu, 2 Jul 2026 10:33:09 -0400 Subject: [PATCH 8/8] YAML: drop unhelpful javadoc on Yaml.Scalar.withBody overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-arg withBody just delegates to the two-arg overload and doesn't need its own docstring. Drop the defaultIndentSpaces explainer from the two-arg overload too — the parameter name is self-descriptive. --- .../src/main/java/org/openrewrite/yaml/tree/Yaml.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 489ba64e760..4f78ce3e2fa 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 @@ -365,7 +365,6 @@ public String getBody() { return out.toString(); } - /** {@link #withBody(String, int)} with a 2-space empty-body indent fallback. */ public Scalar withBody(String newBody) { return withBody(newBody, 2); } @@ -373,9 +372,7 @@ public Scalar withBody(String newBody) { /** * Returns a copy with the body replaced. For PLAIN and quoted styles, equivalent to * {@code withValue(newBody)}. For FOLDED / LITERAL, the block envelope and existing - * line-ending convention are preserved; {@code defaultIndentSpaces} is the body indent - * width when the source block scalar had an empty body (pass - * {@code IndentsStyle.getIndentSize()} to honor the document's configured indent). + * line-ending convention are preserved. */ public Scalar withBody(String newBody, int defaultIndentSpaces) { if (!isBlockStyle()) {