Skip to content

Commit 10ffca7

Browse files
Add YAML StringUtils.quoteIfNeeded() for safe scalar quoting (#7416)
* Add YAML StringUtils.quoteIfNeeded() for safe scalar quoting Add a utility method that quotes YAML scalar values only when syntactically necessary (indicator characters, colon-space, space-hash, document markers, whitespace, control characters). Uses minimal quoting: single quotes by default, double quotes only when escape sequences are needed. * Parameterize YAML StringUtilsTest for quoteIfNeeded Collapse the 43 near-identical @test methods into 8 @ParameterizedTest methods (plus one @test for the single empty-string case) grouped by the existing category headers, so the focus is on the (input, expected) table rather than repeated assertion boilerplate. --------- Co-authored-by: Tim te Beek <tim@moderne.io>
1 parent 30a85e1 commit 10ffca7

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.yaml.internal;
17+
18+
public class StringUtils {
19+
20+
private StringUtils() {
21+
}
22+
23+
private static final String INDICATOR_CHARS = "-?:,[]{}#&*!|>'\"%@`";
24+
25+
/**
26+
* Quotes a YAML scalar value if needed, per the YAML 1.2.2 spec.
27+
* <p>
28+
* If the input is already quoted (surrounded by matching single or double quotes),
29+
* it is returned as-is. Otherwise, the method determines whether the value requires
30+
* quoting and applies the minimal quoting style: single quotes by default, double
31+
* quotes only when escape sequences are required.
32+
*
33+
* @param value the raw string value
34+
* @return the value, potentially quoted for safe use as a YAML scalar
35+
*/
36+
public static String quoteIfNeeded(String value) {
37+
if (isAlreadyQuoted(value)) {
38+
return value;
39+
}
40+
if (!needsQuoting(value)) {
41+
return value;
42+
}
43+
if (needsDoubleQuotes(value)) {
44+
return "\"" + escapeForDoubleQuoting(value) + "\"";
45+
}
46+
return "'" + value + "'";
47+
}
48+
49+
private static boolean isAlreadyQuoted(String value) {
50+
if (value.length() < 2) {
51+
return false;
52+
}
53+
return (value.charAt(0) == '\'' && value.charAt(value.length() - 1) == '\'') ||
54+
(value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"');
55+
}
56+
57+
private static boolean needsQuoting(String value) {
58+
// Empty string
59+
if (value.isEmpty()) {
60+
return true;
61+
}
62+
63+
// Leading or trailing whitespace
64+
char first = value.charAt(0);
65+
char last = value.charAt(value.length() - 1);
66+
if (first == ' ' || first == '\t' || last == ' ' || last == '\t') {
67+
return true;
68+
}
69+
70+
// Contains line breaks
71+
if (value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
72+
return true;
73+
}
74+
75+
// Starts with indicator character
76+
if (INDICATOR_CHARS.indexOf(first) >= 0) {
77+
return true;
78+
}
79+
80+
// Contains colon-space or space-hash mid-string
81+
if (value.contains(": ") || value.contains(" #")) {
82+
return true;
83+
}
84+
85+
// Document markers
86+
if (value.equals("---") || value.equals("...")) {
87+
return true;
88+
}
89+
90+
// Contains control characters
91+
for (int i = 0; i < value.length(); i++) {
92+
char c = value.charAt(i);
93+
if (c < 0x20 || c == 0x7F) {
94+
return true;
95+
}
96+
}
97+
98+
return false;
99+
}
100+
101+
private static boolean needsDoubleQuotes(String value) {
102+
// Need double quotes if value contains single quote
103+
if (value.indexOf('\'') >= 0) {
104+
return true;
105+
}
106+
// Need double quotes if value contains control characters
107+
for (int i = 0; i < value.length(); i++) {
108+
char c = value.charAt(i);
109+
if (c < 0x20 || c == 0x7F) {
110+
return true;
111+
}
112+
}
113+
return false;
114+
}
115+
116+
private static String escapeForDoubleQuoting(String value) {
117+
StringBuilder sb = new StringBuilder(value.length() + 16);
118+
for (int i = 0; i < value.length(); i++) {
119+
char c = value.charAt(i);
120+
switch (c) {
121+
case '\\':
122+
sb.append("\\\\");
123+
break;
124+
case '"':
125+
sb.append("\\\"");
126+
break;
127+
case '\0':
128+
sb.append("\\0");
129+
break;
130+
case '\u0007':
131+
sb.append("\\a");
132+
break;
133+
case '\b':
134+
sb.append("\\b");
135+
break;
136+
case '\t':
137+
sb.append("\\t");
138+
break;
139+
case '\n':
140+
sb.append("\\n");
141+
break;
142+
case '\u000B':
143+
sb.append("\\v");
144+
break;
145+
case '\f':
146+
sb.append("\\f");
147+
break;
148+
case '\r':
149+
sb.append("\\r");
150+
break;
151+
case '\u001B':
152+
sb.append("\\e");
153+
break;
154+
default:
155+
if (c < 0x20 || c == 0x7F) {
156+
sb.append("\\x");
157+
sb.append(String.format("%02x", (int) c));
158+
} else {
159+
sb.append(c);
160+
}
161+
break;
162+
}
163+
}
164+
return sb.toString();
165+
}
166+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.yaml.internal;
17+
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.params.ParameterizedTest;
20+
import org.junit.jupiter.params.provider.Arguments;
21+
import org.junit.jupiter.params.provider.MethodSource;
22+
23+
import java.util.stream.Stream;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.openrewrite.yaml.internal.StringUtils.quoteIfNeeded;
27+
28+
class StringUtilsTest {
29+
30+
@ParameterizedTest
31+
@MethodSource
32+
void alreadyQuotedPassthrough(String input, String expected) {
33+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
34+
}
35+
36+
static Stream<Arguments> alreadyQuotedPassthrough() {
37+
return Stream.of(
38+
Arguments.of("'hello'", "'hello'"),
39+
Arguments.of("\"hello\"", "\"hello\""),
40+
Arguments.of("''", "''")
41+
);
42+
}
43+
44+
@ParameterizedTest
45+
@MethodSource
46+
void noQuotingNeeded(String input, String expected) {
47+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
48+
}
49+
50+
static Stream<Arguments> noQuotingNeeded() {
51+
return Stream.of(
52+
Arguments.of("hello", "hello"),
53+
Arguments.of("hello world", "hello world"),
54+
Arguments.of("some-key", "some-key"),
55+
Arguments.of("path/to/file", "path/to/file"),
56+
Arguments.of("1.0.0", "1.0.0"),
57+
Arguments.of("truefalse", "truefalse"),
58+
Arguments.of("https://example.com", "https://example.com")
59+
);
60+
}
61+
62+
@Test
63+
void emptyStringQuoted() {
64+
assertThat(quoteIfNeeded("")).isEqualTo("''");
65+
}
66+
67+
@ParameterizedTest
68+
@MethodSource
69+
void validYamlTypedValuesUnchanged(String input, String expected) {
70+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
71+
}
72+
73+
static Stream<Arguments> validYamlTypedValuesUnchanged() {
74+
return Stream.of(
75+
Arguments.of("null", "null"),
76+
Arguments.of("true", "true"),
77+
Arguments.of("false", "false"),
78+
Arguments.of("YES", "YES"),
79+
Arguments.of("123", "123"),
80+
Arguments.of("1.23", "1.23")
81+
);
82+
}
83+
84+
@ParameterizedTest
85+
@MethodSource
86+
void indicatorCharactersQuoted(String input, String expected) {
87+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
88+
}
89+
90+
static Stream<Arguments> indicatorCharactersQuoted() {
91+
return Stream.of(
92+
Arguments.of("~", "~"),
93+
Arguments.of("- item", "'- item'"),
94+
Arguments.of("-42", "'-42'"),
95+
Arguments.of("# comment", "'# comment'"),
96+
Arguments.of("*alias", "'*alias'"),
97+
Arguments.of("&anchor", "'&anchor'"),
98+
Arguments.of("[list]", "'[list]'"),
99+
Arguments.of("{map}", "'{map}'"),
100+
Arguments.of("!tag", "'!tag'"),
101+
Arguments.of("%directive", "'%directive'")
102+
);
103+
}
104+
105+
@ParameterizedTest
106+
@MethodSource
107+
void dangerousMidStringPatternsQuoted(String input, String expected) {
108+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
109+
}
110+
111+
static Stream<Arguments> dangerousMidStringPatternsQuoted() {
112+
return Stream.of(
113+
Arguments.of("key: value", "'key: value'"),
114+
Arguments.of("TODO: Follow this link https://example.com/page",
115+
"'TODO: Follow this link https://example.com/page'"),
116+
Arguments.of("before # after", "'before # after'")
117+
);
118+
}
119+
120+
@ParameterizedTest
121+
@MethodSource
122+
void documentMarkersQuoted(String input, String expected) {
123+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
124+
}
125+
126+
static Stream<Arguments> documentMarkersQuoted() {
127+
return Stream.of(
128+
Arguments.of("---", "'---'"),
129+
Arguments.of("...", "'...'")
130+
);
131+
}
132+
133+
@ParameterizedTest
134+
@MethodSource
135+
void leadingTrailingWhitespaceQuoted(String input, String expected) {
136+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
137+
}
138+
139+
static Stream<Arguments> leadingTrailingWhitespaceQuoted() {
140+
return Stream.of(
141+
Arguments.of(" hello", "' hello'"),
142+
Arguments.of("hello ", "'hello '"),
143+
Arguments.of("\thello", "\"\\thello\"")
144+
);
145+
}
146+
147+
@ParameterizedTest
148+
@MethodSource
149+
void doubleQuoteCases(String input, String expected) {
150+
assertThat(quoteIfNeeded(input)).isEqualTo(expected);
151+
}
152+
153+
static Stream<Arguments> doubleQuoteCases() {
154+
return Stream.of(
155+
// Contains ': ' (needs quoting) AND single quote (forces double quotes)
156+
Arguments.of("it's: a test", "\"it's: a test\""),
157+
Arguments.of("line1\nline2", "\"line1\\nline2\""),
158+
Arguments.of("col1\tcol2", "\"col1\\tcol2\""),
159+
Arguments.of("it's", "it's"),
160+
Arguments.of("line1\rline2", "\"line1\\rline2\""),
161+
Arguments.of("abc\0def", "\"abc\\0def\""),
162+
Arguments.of("path\\to", "path\\to"),
163+
Arguments.of("say \"hello\"", "say \"hello\"")
164+
);
165+
}
166+
}

0 commit comments

Comments
 (0)