|
| 1 | +package org.virtuslab.yaml.internal.dump.present |
| 2 | + |
| 3 | +import org.virtuslab.yaml._ |
| 4 | +import org.virtuslab.yaml.internal.load.parse.EventKind._ |
| 5 | +import org.virtuslab.yaml.internal.load.parse.NodeEventMetadata |
| 6 | +import org.virtuslab.yaml.internal.load.reader.token.ScalarStyle |
| 7 | + |
| 8 | +class PresenterBugsSuite extends munit.FunSuite { |
| 9 | + |
| 10 | + private val nl = System.lineSeparator() |
| 11 | + |
| 12 | + private def presentScalar( |
| 13 | + value: String, |
| 14 | + tag: Tag = Tag.str, |
| 15 | + style: ScalarStyle = ScalarStyle.Plain |
| 16 | + ): String = |
| 17 | + PresenterImpl.asString( |
| 18 | + Seq( |
| 19 | + DocumentStart(), |
| 20 | + Scalar(value, style, NodeEventMetadata(tag = Some(tag))), |
| 21 | + DocumentEnd() |
| 22 | + ) |
| 23 | + ) |
| 24 | + |
| 25 | + // ----------------------------------------------------------------------- |
| 26 | + // Bug 1: Trailing whitespace check is inside the `case _` default branch. |
| 27 | + // |
| 28 | + // The trailing-whitespace check in requiresDoubleQuoting only runs when |
| 29 | + // the first character doesn't match any explicit case. Strings like |
| 30 | + // "true " (first char 't') skip it, so trailing whitespace is not detected. |
| 31 | + // YAML strips trailing whitespace on plain scalars (§7.3.3), causing |
| 32 | + // data corruption. |
| 33 | + // ----------------------------------------------------------------------- |
| 34 | + |
| 35 | + test("bug1: trailing whitespace on boolean-like string must be quoted") { |
| 36 | + val result = presentScalar("true ") |
| 37 | + assert( |
| 38 | + result.contains("\"true \""), |
| 39 | + s"Expected 'true ' to be double-quoted, but got: $result" |
| 40 | + ) |
| 41 | + } |
| 42 | + |
| 43 | + test("bug1: trailing whitespace on number-like string must be quoted") { |
| 44 | + val result = presentScalar("123 ") |
| 45 | + assert( |
| 46 | + result.contains("\"123 \""), |
| 47 | + s"Expected '123 ' to be double-quoted, but got: $result" |
| 48 | + ) |
| 49 | + } |
| 50 | + |
| 51 | + test("bug1: trailing whitespace on null-like string must be quoted") { |
| 52 | + val result = presentScalar("null ") |
| 53 | + assert( |
| 54 | + result.contains("\"null \""), |
| 55 | + s"Expected 'null ' to be double-quoted, but got: $result" |
| 56 | + ) |
| 57 | + } |
| 58 | + |
| 59 | + test("bug1: trailing whitespace on float-like string must be quoted") { |
| 60 | + val result = presentScalar("3.14 ") |
| 61 | + assert( |
| 62 | + result.contains("\"3.14 \""), |
| 63 | + s"Expected '3.14 ' to be double-quoted, but got: $result" |
| 64 | + ) |
| 65 | + } |
| 66 | + |
| 67 | + // ----------------------------------------------------------------------- |
| 68 | + // Bug 2: Trailing colon not handled. |
| 69 | + // |
| 70 | + // "a:" is not quoted. Per §7.3.3 rule [130], `:` in a plain scalar |
| 71 | + // requires a following ns-plain-safe character. A trailing colon |
| 72 | + // (followed by newline/EOF) fails that lookahead and gets parsed as |
| 73 | + // a mapping value indicator. |
| 74 | + // ----------------------------------------------------------------------- |
| 75 | + |
| 76 | + test("bug2: trailing colon must be quoted") { |
| 77 | + val result = presentScalar("a:") |
| 78 | + assert( |
| 79 | + result.contains("\"a:\""), |
| 80 | + s"Expected 'a:' to be double-quoted, but got: $result" |
| 81 | + ) |
| 82 | + } |
| 83 | + |
| 84 | + test("bug2: trailing colon followed by nothing should be quoted") { |
| 85 | + val result = presentScalar("host:") |
| 86 | + assert( |
| 87 | + result.contains("\"host:\""), |
| 88 | + s"Expected 'host:' to be double-quoted, but got: $result" |
| 89 | + ) |
| 90 | + } |
| 91 | + |
| 92 | + // ----------------------------------------------------------------------- |
| 93 | + // Bug 4: `tag.exists(_ eq Tag.str)` uses reference equality. |
| 94 | + // |
| 95 | + // Per §3.2.1.3, tags must be compared by character-by-character string |
| 96 | + // comparison. A Tag constructed for "tag:yaml.org,2002:str" that isn't |
| 97 | + // the Tag.str singleton falls to the `else sb.append(value)` branch: |
| 98 | + // unquoted and unescaped. |
| 99 | + // ----------------------------------------------------------------------- |
| 100 | + |
| 101 | + test("bug4: non-singleton str tag must still trigger quoting logic") { |
| 102 | + val nonSingletonStr = CoreSchemaTag("tag:yaml.org,2002:str") |
| 103 | + assert( |
| 104 | + nonSingletonStr != Tag.str || !(nonSingletonStr eq Tag.str), |
| 105 | + "Precondition: nonSingletonStr should not be reference-equal to Tag.str" |
| 106 | + ) |
| 107 | + val result = presentScalar("true", tag = nonSingletonStr) |
| 108 | + assert( |
| 109 | + result.contains("\"true\""), |
| 110 | + s"Expected 'true' with non-singleton str tag to be double-quoted, but got: $result" |
| 111 | + ) |
| 112 | + } |
| 113 | + |
| 114 | + test("bug4: non-singleton null tag must still trigger null rendering") { |
| 115 | + val nonSingletonNull = CoreSchemaTag("tag:yaml.org,2002:null") |
| 116 | + val result = presentScalar("", tag = nonSingletonNull) |
| 117 | + assert( |
| 118 | + result.contains("!!null"), |
| 119 | + s"Expected empty string with non-singleton null tag to render as !!null, but got: $result" |
| 120 | + ) |
| 121 | + } |
| 122 | + |
| 123 | + test("bug4: scalar with no tag should still apply quoting for ambiguous values") { |
| 124 | + val events = Seq( |
| 125 | + DocumentStart(), |
| 126 | + Scalar("true", ScalarStyle.Plain, NodeEventMetadata.empty), |
| 127 | + DocumentEnd() |
| 128 | + ) |
| 129 | + val result = PresenterImpl.asString(events) |
| 130 | + assert( |
| 131 | + !result.trim.equals("true"), |
| 132 | + s"Expected 'true' with no tag to be quoted or handled safely, but got: $result" |
| 133 | + ) |
| 134 | + } |
| 135 | + |
| 136 | + // ----------------------------------------------------------------------- |
| 137 | + // Bug 6: escapeFolded doesn't handle folded-style semantics. |
| 138 | + // |
| 139 | + // Per §6.5, §8.1.3, in folded style single newlines between non-empty |
| 140 | + // lines are converted to spaces (rule [72] b-as-space). The current |
| 141 | + // implementation is identical to literal style, so content with single |
| 142 | + // newlines will not round-trip correctly. |
| 143 | + // ----------------------------------------------------------------------- |
| 144 | + |
| 145 | +// test("bug6: folded style must round-trip single newlines correctly") { |
| 146 | +// val input = "abc\ndef" |
| 147 | +// val folded = presentScalar(input, style = ScalarStyle.Folded) |
| 148 | +// val parsed = folded.as[String] |
| 149 | +// parsed match { |
| 150 | +// case Right(result) => |
| 151 | +// assertEquals( |
| 152 | +// result, |
| 153 | +// input, |
| 154 | +// s"Folded scalar did not round-trip. Emitted:\n$folded" |
| 155 | +// ) |
| 156 | +// case Left(err) => |
| 157 | +// fail(s"Failed to parse folded output: ${err.msg}\nEmitted:\n$folded") |
| 158 | +// } |
| 159 | +// } |
| 160 | + |
| 161 | + // ----------------------------------------------------------------------- |
| 162 | + // Bug 7: Block scalars at top level emit at indent == 0. |
| 163 | + // |
| 164 | + // Per §8.1.1.1 and §6.1, block scalar content indentation level is |
| 165 | + // n + m, and the explicit indicator range is 1-9 (rule [163]). |
| 166 | + // At n=0, content must still be indented at least 1 space. |
| 167 | + // ----------------------------------------------------------------------- |
| 168 | + |
| 169 | +// test("bug7: literal block scalar at top level must indent content") { |
| 170 | +// val input = "abc\ndef" |
| 171 | +// val result = presentScalar(input, style = ScalarStyle.Literal) |
| 172 | +// val parsed = result.as[String] |
| 173 | +// parsed match { |
| 174 | +// case Right(value) => |
| 175 | +// assertEquals( |
| 176 | +// value, |
| 177 | +// input, |
| 178 | +// s"Literal scalar at top level did not round-trip. Emitted:\n$result" |
| 179 | +// ) |
| 180 | +// case Left(err) => |
| 181 | +// fail(s"Failed to parse literal output at top level: ${err.msg}\nEmitted:\n$result") |
| 182 | +// } |
| 183 | +// } |
| 184 | + |
| 185 | +// test("bug7: folded block scalar at top level must indent content") { |
| 186 | +// val input = "abc\ndef\n" |
| 187 | +// val result = presentScalar(input, style = ScalarStyle.Folded) |
| 188 | +// val parsed = result.as[String] |
| 189 | +// parsed match { |
| 190 | +// case Right(value) => |
| 191 | +// assertEquals( |
| 192 | +// value, |
| 193 | +// input, |
| 194 | +// s"Folded scalar at top level did not round-trip. Emitted:\n$result" |
| 195 | +// ) |
| 196 | +// case Left(err) => |
| 197 | +// fail(s"Failed to parse folded output at top level: ${err.msg}\nEmitted:\n$result") |
| 198 | +// } |
| 199 | +// } |
| 200 | + |
| 201 | + // ----------------------------------------------------------------------- |
| 202 | + // Bug 8: sys.error on non-scalar mapping keys is a regression. |
| 203 | + // |
| 204 | + // Per §3.2.1.1, "keys may be arbitrary nodes." Complex keys are legal |
| 205 | + // YAML and the `?` indicator exists for emitting them. Throwing a bare |
| 206 | + // RuntimeException is non-compliant. |
| 207 | + // ----------------------------------------------------------------------- |
| 208 | + |
| 209 | + test("bug8: non-scalar mapping key should not throw") { |
| 210 | + val events = Seq( |
| 211 | + DocumentStart(), |
| 212 | + MappingStart(), |
| 213 | + MappingStart(), |
| 214 | + Scalar("nested", metadata = NodeEventMetadata(tag = Some(Tag.str))), |
| 215 | + Scalar("key", metadata = NodeEventMetadata(tag = Some(Tag.str))), |
| 216 | + MappingEnd, |
| 217 | + Scalar("value", metadata = NodeEventMetadata(tag = Some(Tag.str))), |
| 218 | + MappingEnd, |
| 219 | + DocumentEnd() |
| 220 | + ) |
| 221 | + val result = |
| 222 | + try { |
| 223 | + val yaml = PresenterImpl.asString(events) |
| 224 | + Right(yaml) |
| 225 | + } catch { |
| 226 | + case e: RuntimeException => Left(e.getMessage) |
| 227 | + } |
| 228 | + assert( |
| 229 | + result.isRight, |
| 230 | + s"Non-scalar mapping key should not throw, but got: ${result.left.getOrElse("")}" |
| 231 | + ) |
| 232 | + } |
| 233 | + |
| 234 | + // ----------------------------------------------------------------------- |
| 235 | + // Roundtrip: per §3.2.1, §3.3.2, §10.3.2, a !!str node whose content |
| 236 | + // matches a non-string resolution pattern (null, true, 3.9, etc.) must |
| 237 | + // be quoted or explicitly tagged. Otherwise re-parsing resolves it to |
| 238 | + // the wrong type. |
| 239 | + // ----------------------------------------------------------------------- |
| 240 | + |
| 241 | + test("roundtrip: string 'null' must survive encode-decode as string") { |
| 242 | + val yaml = "null".asYaml |
| 243 | + val parsed = yaml.as[String] |
| 244 | + parsed match { |
| 245 | + case Right(value) => |
| 246 | + assertEquals(value, "null", s"String 'null' did not round-trip. YAML was: $yaml") |
| 247 | + case Left(err) => |
| 248 | + fail(s"Failed to parse: ${err.msg}") |
| 249 | + } |
| 250 | + } |
| 251 | +} |
0 commit comments