| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import * as Yaml from "effect/encoding/Yaml" |
| 3 | |
| 4 | describe("Yaml", () => { |
| 5 | it("parses nested block and flow collections", () => { |
| 6 | assert.deepStrictEqual( |
| 7 | Yaml.parse(` |
| 8 | name: effect |
| 9 | enabled: true |
| 10 | ports: [3000, 3001] |
| 11 | database: |
| 12 | host: localhost |
| 13 | credentials: |
| 14 | - user: root |
| 15 | roles: [admin, writer] |
| 16 | - user: guest |
| 17 | roles: [] |
| 18 | `), |
| 19 | { |
| 20 | name: "effect", |
| 21 | enabled: true, |
| 22 | ports: [3000, 3001], |
| 23 | database: { |
| 24 | host: "localhost", |
| 25 | credentials: [ |
| 26 | { user: "root", roles: ["admin", "writer"] }, |
| 27 | { user: "guest", roles: [] } |
| 28 | ] |
| 29 | } |
| 30 | } |
| 31 | ) |
| 32 | }) |
| 33 | |
| 34 | it("parses quoted and block scalars", () => { |
| 35 | assert.deepStrictEqual( |
| 36 | Yaml.parse(` |
| 37 | quoted: "line\\nvalue" |
| 38 | literal: | |
| 39 | first |
| 40 | second |
| 41 | folded: >- |
| 42 | first |
| 43 | second |
| 44 | `), |
| 45 | { |
| 46 | quoted: "line\nvalue", |
| 47 | literal: "first\nsecond\n", |
| 48 | folded: "first second" |
| 49 | } |
| 50 | ) |
| 51 | }) |
| 52 | |
| 53 | it("preserves paragraph and indentation breaks in folded block scalars", () => { |
| 54 | assert.deepStrictEqual( |
| 55 | Yaml.parse(` |
| 56 | paragraph: >- |
| 57 | first |
| 58 | |
| 59 | second |
| 60 | indented: >- |
| 61 | first |
| 62 | second |
| 63 | third |
| 64 | `), |
| 65 | { |
| 66 | paragraph: "first\nsecond", |
| 67 | indented: "first\n second\nthird" |
| 68 | } |
| 69 | ) |
| 70 | }) |
| 71 | |
| 72 | it.each([ |
| 73 | [ |
| 74 | "leading blanks before a more-indented first line", |
| 75 | "message: >-2\n\n first\n second\n", |
| 76 | "\n first\nsecond" |
| 77 | ], |
| 78 | [ |
| 79 | "keep chomping after a more-indented final line", |
| 80 | "message: >+2\n first\n second\n", |
| 81 | "first\n second\n" |
| 82 | ], |
| 83 | [ |
| 84 | "multiple blank paragraph lines", |
| 85 | "message: >-\n first\n\n\n second\n", |
| 86 | "first\n\nsecond" |
| 87 | ] |
| 88 | ])("preserves %s", (_, source, expected) => { |
| 89 | assert.deepStrictEqual(Yaml.parse(source), { message: expected }) |
| 90 | }) |
| 91 | |
| 92 | it("resolves aliases", () => { |
| 93 | assert.deepStrictEqual( |
| 94 | Yaml.parse(` |
| 95 | defaults: &defaults |
| 96 | host: localhost |
| 97 | port: 5432 |
| 98 | development: |
| 99 | settings: *defaults |
| 100 | `), |
| 101 | { |
| 102 | defaults: { host: "localhost", port: 5432 }, |
| 103 | development: { settings: { host: "localhost", port: 5432 } } |
| 104 | } |
| 105 | ) |
| 106 | }) |
| 107 | |
| 108 | it("rejects invalid indentation", () => { |
| 109 | assert.throws(() => Yaml.parse("root:\n child: true\n sibling: false\n")) |
| 110 | }) |
| 111 | |
| 112 | // YAML 1.2.2 §8.2.1: a mapping value's block sequence may be indentless. |
| 113 | describe("indentless sequences", () => { |
| 114 | it.each([ |
| 115 | { |
| 116 | name: "top-level mapping value", |
| 117 | source: "allowed-tools:\n- Read\n- Bash\n", |
| 118 | expected: { "allowed-tools": ["Read", "Bash"] } |
| 119 | }, |
| 120 | { |
| 121 | name: "nested mapping value", |
| 122 | source: "metadata:\n tools:\n - Read\n", |
| 123 | expected: { metadata: { tools: ["Read"] } } |
| 124 | }, |
| 125 | { |
| 126 | name: "multiple sequences and sibling keys", |
| 127 | source: "tools:\n- Read\n- Bash\nroles:\n- admin\nenabled: true\n", |
| 128 | expected: { tools: ["Read", "Bash"], roles: ["admin"], enabled: true } |
| 129 | }, |
| 130 | { |
| 131 | name: "comments and blank lines before and between items", |
| 132 | source: "tools: # key comment\n# before sequence\n\n- Read # item comment\n # between items\n\n- Bash\n", |
| 133 | expected: { tools: ["Read", "Bash"] } |
| 134 | }, |
| 135 | { |
| 136 | name: "nested mapping items and their indentless sequences", |
| 137 | source: "steps:\n- name: deploy\n tools:\n - Read\n - Bash\n enabled: true\n- name: verify\n", |
| 138 | expected: { steps: [{ name: "deploy", tools: ["Read", "Bash"], enabled: true }, { name: "verify" }] } |
| 139 | }, |
| 140 | { |
| 141 | name: "nested sequences ending at mapping boundaries", |
| 142 | source: "metadata:\n tools:\n - Read\n roles:\n - admin\n enabled: true\nname: deploy\n", |
| 143 | expected: { metadata: { tools: ["Read"], roles: ["admin"], enabled: true }, name: "deploy" } |
| 144 | }, |
| 145 | { |
| 146 | name: "multiline descriptions alongside sequences", |
| 147 | source: |
| 148 | "description: Deploy the service\n and verify it.\ntools:\n- Read\nmetadata:\n description: 'Check\n the logs'\n tools:\n - Bash\n", |
| 149 | expected: { |
| 150 | description: "Deploy the service and verify it.", |
| 151 | tools: ["Read"], |
| 152 | metadata: { description: "Check the logs", tools: ["Bash"] } |
| 153 | } |
| 154 | } |
| 155 | ])("parses $name", ({ source, expected }) => { |
| 156 | assert.deepStrictEqual(Yaml.parse(source), expected) |
| 157 | }) |
| 158 | }) |
| 159 | |
| 160 | // YAML 1.2.2 §§6.5–6.7 and 7.3.1–7.3.3: flow scalar folding and comments. |
| 161 | // Expected values and rejection fixtures were checked with yaml 2.9.1. |
| 162 | describe("multiline flow scalars", () => { |
| 163 | it.each([ |
| 164 | [ |
| 165 | "plain scalar starting below the key", |
| 166 | "description:\n Deploy the service\n and verify it.\n", |
| 167 | "Deploy the service and verify it." |
| 168 | ], |
| 169 | [ |
| 170 | "plain scalar starting on the key line", |
| 171 | "description: Deploy the service\n and verify it.\n", |
| 172 | "Deploy the service and verify it." |
| 173 | ], |
| 174 | [ |
| 175 | "varying continuation indentation", |
| 176 | "description:\n Deploy\n the service\n and verify it.\n", |
| 177 | "Deploy the service and verify it." |
| 178 | ], |
| 179 | ["one blank line", "description: Deploy\n\n and verify.\n", "Deploy\nand verify."], |
| 180 | ["multiple blank lines", "description: Deploy\n\n\n and verify.\n", "Deploy\n\nand verify."], |
| 181 | ["whitespace-only blank line", "description: Deploy\n \n and verify.\n", "Deploy\nand verify."], |
| 182 | [ |
| 183 | "leading and trailing comments", |
| 184 | "# before key\ndescription: # before value\n # before scalar\n Deploy\n and verify. # after scalar\n# after value\n", |
| 185 | "Deploy and verify." |
| 186 | ], |
| 187 | ["trailing whitespace", "description: Deploy \n and verify. \n", "Deploy and verify."], |
| 188 | ["CRLF line breaks", "description: Deploy\r\n and verify.\r\n", "Deploy and verify."], |
| 189 | ["double-quoted scalar", "description: \"Deploy it\n and more\"\n", "Deploy it and more"], |
| 190 | ["single-quoted scalar", "description: 'Deploy it\n and more'\n", "Deploy it and more"], |
| 191 | ["single-quoted apostrophe", "description: 'Deploy it\n and don''t stop'\n", "Deploy it and don't stop"], |
| 192 | ["double-quoted blank lines", "description: \"Deploy\n\n\n and verify.\"\n", "Deploy\n\nand verify."], |
| 193 | ["single-quoted blank line", "description: 'Deploy\n\n and verify.'\n", "Deploy\nand verify."], |
| 194 | ["quoted whitespace folding", "description: \"Deploy \n and verify.\"\n", "Deploy and verify."], |
| 195 | ["double-quoted escaped line break", "description: \"De\\\n ploy\"\n", "Deploy"], |
| 196 | ["space before escaped line break", "description: \"Deploy \\\n it\"\n", "Deploy it"], |
| 197 | ["escaped line break followed by blank line", "description: \"Deploy\\\n\n it\"\n", "Deploy it"], |
| 198 | ["space before escaped line break followed by blank line", "description: \"Deploy \\\n\n it\"\n", "Deploy it"], |
| 199 | ["escaped leading space after line break", "description: \"Deploy\\\n \\ it\"\n", "Deploy it"], |
| 200 | ["escaped trailing space before a folded break", "description: \"Deploy\\ \n it\"\n", "Deploy it"], |
| 201 | ["escaped break followed by multiple blank lines", "description: \"Deploy\\\n\n\n it\"\n", "Deploy\nit"], |
| 202 | ["hash inside double quotes", "description: \"Deploy\n # still text\" # comment\n", "Deploy # still text"], |
| 203 | ["hash inside single quotes", "description: 'Deploy\n # still text' # comment\n", "Deploy # still text"], |
| 204 | [ |
| 205 | "colon and hash without separating spaces", |
| 206 | "description: Visit https://example.com\n for issue#123\n", |
| 207 | "Visit https://example.com for issue#123" |
| 208 | ], |
| 209 | ["apostrophe in plain text before a comment", "description: Don't deploy # comment\n", "Don't deploy"], |
| 210 | ["quote in plain text before a comment", "description: Use a \"quote # comment\n", "Use a \"quote"], |
| 211 | ["single quote starting a plain continuation", "description: Deploy\n 'it # end\n", "Deploy 'it"], |
| 212 | ["double quote starting a plain continuation", "description: Deploy\n \"it # end\n", "Deploy \"it"], |
| 213 | ["bracket and quote inside plain text", "description: plain [ a 'quote # comment\n", "plain [ a 'quote"] |
| 214 | ])("folds %s", (_, source, expected) => { |
| 215 | assert.deepStrictEqual(Yaml.parse(source), { description: expected }) |
| 216 | const nested = source.split(/\r?\n/).map((line) => ` ${line}`).join("\n") |
| 217 | assert.deepStrictEqual(Yaml.parse(`metadata:\n${nested}`), { metadata: { description: expected } }) |
| 218 | }) |
| 219 | |
| 220 | it("ends plain scalars at sibling keys after terminating comments", () => { |
| 221 | assert.deepStrictEqual( |
| 222 | Yaml.parse("description: Deploy\n and verify. # end\n# between keys\nenabled: true\n"), |
| 223 | { description: "Deploy and verify.", enabled: true } |
| 224 | ) |
| 225 | }) |
| 226 | |
| 227 | it("folds plain sequence items", () => { |
| 228 | assert.deepStrictEqual(Yaml.parse("- Deploy\n and verify.\n- Finish\n"), ["Deploy and verify.", "Finish"]) |
| 229 | }) |
| 230 | }) |
| 231 | |
| 232 | describe("scalar conformance boundaries", () => { |
| 233 | it.each([ |
| 234 | ["colon followed by space", "description: Use when: deploy\n"], |
| 235 | ["colon at end of plain scalar", "description: Deploy:\n"], |
| 236 | ["colon followed by tab", "description: Use when:\tdeploy\n"], |
| 237 | ["colon in a continuation", "description: Deploy\n when: ready\n"], |
| 238 | ["continuation after an inline comment", "description: Deploy # end\n and verify.\n"], |
| 239 | ["continuation after a comment line", "description: Deploy\n # end\n and verify.\n"], |
| 240 | ["continuation after a trailing continuation comment", "description: Deploy\n and verify # end\n again\n"], |
| 241 | ["continuation after a comment following a literal single quote", "description: Deploy\n 'it # end\n again\n"], |
| 242 | ["continuation after a comment following a literal double quote", "description: Deploy\n \"it # end\n again\n"], |
| 243 | ["unterminated double quote", "description: \"Deploy\n and verify\n"], |
| 244 | ["unterminated single quote", "description: 'Deploy\n and verify\n"], |
| 245 | ["content after closing double quote", "description: \"Deploy\n it\" extra\n"], |
| 246 | ["content after closing single quote", "description: 'Deploy\n it' extra\n"], |
| 247 | ["tab-indented scalar", "description:\n\tDeploy\n"], |
| 248 | ["duplicate key", "description: first\ndescription: second\n"] |
| 249 | ])("rejects %s", (_, source) => { |
| 250 | assert.throws(() => Yaml.parse(source), SyntaxError) |
| 251 | }) |
| 252 | |
| 253 | it("preserves numeric decoding and quoted colon content", () => { |
| 254 | assert.deepStrictEqual( |
| 255 | Yaml.parse("description: 123\nquoted: 'Use when: deploy'\ntrailing: \"Deploy:\"\n"), |
| 256 | { description: 123, quoted: "Use when: deploy", trailing: "Deploy:" } |
| 257 | ) |
| 258 | }) |
| 259 | }) |
| 260 | |
| 261 | describe("malformed content around compatibility shapes", () => { |
| 262 | const shapes = [ |
| 263 | ["indentless sequence", "tools:\n- Read\n- Bash\n"], |
| 264 | ["nested indentless sequence", "metadata:\n tools:\n - Read\n"], |
| 265 | ["multiline plain scalar", "description: Deploy\n and verify.\n"], |
| 266 | ["multiline quoted scalar", "description: \"Deploy\n and verify.\"\n"] |
| 267 | ] |
| 268 | it.each([ |
| 269 | ["unterminated flow sequence", "broken: [one, two\n"], |
| 270 | ["unterminated flow mapping", "broken: {one: two\n"], |
| 271 | ["unmatched top-level line", "broken: true\nunmatched\n"], |
| 272 | ["tab indentation", "broken:\n\tchild: true\n"], |
| 273 | ["invalid mapping indentation", "broken:\n child: true\n sibling: false\n"], |
| 274 | ["duplicate keys", "broken: one\nbroken: two\n"] |
| 275 | ])("rejects %s alone and before or after valid shapes", (_, malformed) => { |
| 276 | assert.throws(() => Yaml.parse(malformed), SyntaxError) |
| 277 | for (const [name, source] of shapes) { |
| 278 | assert.throws(() => Yaml.parse(malformed + source), SyntaxError, undefined, `before ${name}`) |
| 279 | assert.throws(() => Yaml.parse(source + malformed), SyntaxError, undefined, `after ${name}`) |
| 280 | } |
| 281 | }) |
| 282 | }) |
| 283 | |
| 284 | describe("unsupported compact nested block sequences", () => { |
| 285 | // Valid YAML, deliberately rejected as unsupported. |
| 286 | it.each([ |
| 287 | ["indentless mapping value", "a:\n- - 1\n - 2\n"], |
| 288 | ["indented mapping value", "a:\n - - 1\n - 2\n"], |
| 289 | ["root sequence", "- - 1\n - 2\n"], |
| 290 | ["blank line between nested entries", "- - 1\n\n - 2\n"], |
| 291 | ["nested entry after a scalar item", "a:\n- 1\n- - 2\n"], |
| 292 | ["nested mapping item", "a:\n- - b: 1\n"], |
| 293 | ["single-line nested sequence", "- - 1\n"], |
| 294 | ["empty nested entry", "- -\n"] |
| 295 | ])("rejects %s", (_, source) => { |
| 296 | assert.throws(() => Yaml.parse(source), SyntaxError) |
| 297 | }) |
| 298 | |
| 299 | it.each( |
| 300 | [ |
| 301 | ["mapping value", "a: one\n - two\n", { a: "one - two" }], |
| 302 | ["sequence item", "- one\n - two\n", ["one - two"]] |
| 303 | ] as const |
| 304 | )("preserves a dash in a plain continuation of a %s", (_, source, expected) => { |
| 305 | assert.deepStrictEqual(Yaml.parse(source), expected) |
| 306 | }) |
| 307 | }) |
| 308 | |
| 309 | describe("document boundaries", () => { |
| 310 | // YAML 1.2.2 §9.1.2: document markers must start at column zero. |
| 311 | it.each([ |
| 312 | ["---", ""], |
| 313 | ["---", " # comment"], |
| 314 | ["...", ""], |
| 315 | ["...", " # comment"] |
| 316 | ])("preserves indented %s%s as a mapping value", (marker, suffix) => { |
| 317 | assert.deepStrictEqual(Yaml.parse(`a:\n ${marker}${suffix}\n`), { a: marker }) |
| 318 | assert.deepStrictEqual(Yaml.parse(`metadata:\n a:\n ${marker}${suffix}\n`), { metadata: { a: marker } }) |
| 319 | }) |
| 320 | |
| 321 | it.each([ |
| 322 | ["mappings", "a: 1\n---\nb: 2\n"], |
| 323 | ["sequences", "- one\n---\n- two\n"], |
| 324 | ["plain scalars", "one\n---\ntwo\n"], |
| 325 | ["explicit initial marker", "---\na: 1\n--- # second document\nb: 2\n"], |
| 326 | ["empty first document", "---\n---\nb: 2\n"], |
| 327 | ["empty trailing document", "a: 1\n---\n"] |
| 328 | ])("rejects a document stream containing %s", (_, source) => { |
| 329 | assert.throws(() => Yaml.parse(source), SyntaxError) |
| 330 | }) |
| 331 | |
| 332 | it.each( |
| 333 | [ |
| 334 | ["mapping", "# before\n--- # start\na: 1\n... # end\n# after\n", { a: 1 }], |
| 335 | ["sequence", "---\n- one\n- two\n...\n", ["one", "two"]], |
| 336 | ["plain scalar", "---\none\n...\n", "one"], |
| 337 | ["empty document", "---\n...\n", null] |
| 338 | ] as const |
| 339 | )("preserves single-document markers around a %s", (_, source, expected) => { |
| 340 | assert.deepStrictEqual(Yaml.parse(source), expected) |
| 341 | }) |
| 342 | }) |
| 343 | |
| 344 | describe("anchors and quoted comment boundaries", () => { |
| 345 | it.each([ |
| 346 | ["plain", "description: &text Deploy\n and verify.\nalias: *text\n", "Deploy and verify."], |
| 347 | [ |
| 348 | "single-quoted", |
| 349 | "description: &text 'Deploy # first\n # second' # end\nalias: *text\n", |
| 350 | "Deploy # first # second" |
| 351 | ], |
| 352 | [ |
| 353 | "double-quoted", |
| 354 | "description: &text \"Deploy # first\n # second\" # end\nalias: *text\n", |
| 355 | "Deploy # first # second" |
| 356 | ] |
| 357 | ])("resolves anchored multiline %s scalars", (_, source, expected) => { |
| 358 | assert.deepStrictEqual(Yaml.parse(source), { description: expected, alias: expected }) |
| 359 | }) |
| 360 | |
| 361 | it("resolves anchored nested indentless sequences", () => { |
| 362 | assert.deepStrictEqual( |
| 363 | Yaml.parse("metadata:\n tools: &tools\n - Read\n - Bash\n alias: *tools\n"), |
| 364 | { metadata: { tools: ["Read", "Bash"], alias: ["Read", "Bash"] } } |
| 365 | ) |
| 366 | }) |
| 367 | |
| 368 | it("preserves hashes in JSON-style flow strings without colon spacing", () => { |
| 369 | assert.deepStrictEqual(Yaml.parse("{\"description\":\"Deploy # now\"} # end\n"), { description: "Deploy # now" }) |
| 370 | }) |
| 371 | |
| 372 | it("preserves hashes in single- and double-quoted flow sequence items", () => { |
| 373 | assert.deepStrictEqual( |
| 374 | Yaml.parse("descriptions: [\"Deploy # now\", 'Verify # later'] # end\n"), |
| 375 | { descriptions: ["Deploy # now", "Verify # later"] } |
| 376 | ) |
| 377 | }) |
| 378 | }) |
| 379 | }) |