Pure OCaml Yaml 1.2 reader and writer using Bytesrw
at main 17 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** Tests for the Yamlrw library *) 7 8open Yamlrw 9 10(** Test helpers *) 11 12let check_value msg expected actual = 13 Alcotest.(check bool) msg true (Value.equal expected actual) 14 15let _check_string msg expected actual = 16 Alcotest.(check string) msg expected actual 17 18(** Scanner tests *) 19 20let test_scanner_simple () = 21 let scanner = Scanner.of_string "hello: world" in 22 let tokens = Scanner.to_list scanner in 23 let token_types = List.map (fun (t : Token.spanned) -> t.token) tokens in 24 Alcotest.(check int) "token count" 8 (List.length token_types); 25 (* Stream_start, Block_mapping_start, Key, Scalar, Value, Scalar, Block_end, Stream_end *) 26 match token_types with 27 | [ 28 Token.Stream_start _; 29 Token.Block_mapping_start; 30 Token.Key; 31 Token.Scalar { value = "hello"; _ }; 32 Token.Value; 33 Token.Scalar { value = "world"; _ }; 34 Token.Block_end; 35 Token.Stream_end; 36 ] -> 37 () 38 | _ -> Alcotest.fail "unexpected token sequence" 39 40let test_scanner_sequence () = 41 let scanner = Scanner.of_string "- one\n- two\n- three" in 42 let tokens = Scanner.to_list scanner in 43 Alcotest.(check bool) "has tokens" true (List.length tokens > 0) 44 45let test_scanner_flow () = 46 let scanner = Scanner.of_string "[1, 2, 3]" in 47 let tokens = Scanner.to_list scanner in 48 let has_flow_start = 49 List.exists 50 (fun (t : Token.spanned) -> 51 match t.token with Token.Flow_sequence_start -> true | _ -> false) 52 tokens 53 in 54 Alcotest.(check bool) "has flow sequence start" true has_flow_start 55 56let scanner_tests = 57 [ 58 ("simple mapping", `Quick, test_scanner_simple); 59 ("sequence", `Quick, test_scanner_sequence); 60 ("flow sequence", `Quick, test_scanner_flow); 61 ] 62 63(** Parser tests *) 64 65let test_parser_events () = 66 let parser = Parser.of_string "key: value" in 67 let events = Parser.to_list parser in 68 Alcotest.(check bool) "has events" true (List.length events > 0); 69 let has_stream_start = 70 List.exists 71 (fun (e : Event.spanned) -> 72 match e.event with Event.Stream_start _ -> true | _ -> false) 73 events 74 in 75 Alcotest.(check bool) "has stream start" true has_stream_start 76 77let test_parser_sequence_events () = 78 let parser = Parser.of_string "- a\n- b" in 79 let events = Parser.to_list parser in 80 let has_seq_start = 81 List.exists 82 (fun (e : Event.spanned) -> 83 match e.event with Event.Sequence_start _ -> true | _ -> false) 84 events 85 in 86 Alcotest.(check bool) "has sequence start" true has_seq_start 87 88let parser_tests = 89 [ 90 ("parse events", `Quick, test_parser_events); 91 ("sequence events", `Quick, test_parser_sequence_events); 92 ] 93 94(** Value parsing tests *) 95 96let test_parse_null () = 97 check_value "null" `Null (of_string "null"); 98 check_value "~" `Null (of_string "~"); 99 check_value "empty" `Null (of_string "") 100 101let test_parse_bool () = 102 check_value "true" (`Bool true) (of_string "true"); 103 check_value "false" (`Bool false) (of_string "false"); 104 check_value "yes" (`Bool true) (of_string "yes"); 105 check_value "no" (`Bool false) (of_string "no") 106 107let test_parse_number () = 108 check_value "integer" (`Float 42.0) (of_string "42"); 109 check_value "negative" (`Float (-17.0)) (of_string "-17"); 110 check_value "float" (`Float 3.14) (of_string "3.14") 111 112let test_parse_string () = 113 check_value "plain" (`String "hello") 114 ( of_string "hello world" |> function 115 | `String s -> `String (String.sub s 0 5) 116 | v -> v ); 117 check_value "quoted" (`String "hello") (of_string {|"hello"|}) 118 119let test_parse_sequence () = 120 let result = of_string "- one\n- two\n- three" in 121 match result with 122 | `A [ _; _; _ ] -> () 123 | _ -> Alcotest.fail "expected sequence with 3 elements" 124 125let test_parse_mapping () = 126 let result = of_string "name: Alice\nage: 30" in 127 match result with 128 | `O pairs when List.length pairs = 2 -> () 129 | _ -> Alcotest.fail "expected mapping with 2 pairs" 130 131let test_parse_nested () = 132 let yaml = {| 133person: 134 name: Bob 135 hobbies: 136 - reading 137 - coding 138|} in 139 let result = of_string yaml in 140 match result with 141 | `O [ ("person", `O _) ] -> () 142 | _ -> Alcotest.fail "expected nested structure" 143 144let test_parse_flow_sequence () = 145 let result = of_string "[1, 2, 3]" in 146 match result with 147 | `A [ `Float 1.0; `Float 2.0; `Float 3.0 ] -> () 148 | _ -> Alcotest.fail "expected flow sequence [1, 2, 3]" 149 150let test_parse_flow_mapping () = 151 let result = of_string "{a: 1, b: 2}" in 152 match result with 153 | `O [ ("a", `Float 1.0); ("b", `Float 2.0) ] -> () 154 | _ -> Alcotest.fail "expected flow mapping {a: 1, b: 2}" 155 156let test_parse_flow_mapping_trailing_comma () = 157 let result = of_string "{ a: 1, }" in 158 match result with 159 | `O [ ("a", `Float 1.0) ] -> () 160 | `O pairs -> 161 Alcotest.failf 162 "expected 1 pair but got %d pairs (trailing comma should not create \ 163 empty entry)" 164 (List.length pairs) 165 | _ -> Alcotest.fail "expected flow mapping with 1 pair" 166 167let value_tests = 168 [ 169 ("parse null", `Quick, test_parse_null); 170 ("parse bool", `Quick, test_parse_bool); 171 ("parse number", `Quick, test_parse_number); 172 ("parse string", `Quick, test_parse_string); 173 ("parse sequence", `Quick, test_parse_sequence); 174 ("parse mapping", `Quick, test_parse_mapping); 175 ("parse nested", `Quick, test_parse_nested); 176 ("parse flow sequence", `Quick, test_parse_flow_sequence); 177 ("parse flow mapping", `Quick, test_parse_flow_mapping); 178 ( "flow mapping trailing comma", 179 `Quick, 180 test_parse_flow_mapping_trailing_comma ); 181 ] 182 183(** Emitter tests *) 184 185let test_emit_null () = 186 let result = to_string `Null in 187 Alcotest.(check bool) "contains null" true (String.length result > 0) 188 189let starts_with prefix s = 190 String.length s >= String.length prefix 191 && String.sub s 0 (String.length prefix) = prefix 192 193let test_emit_mapping () = 194 let value = `O [ ("name", `String "Alice"); ("age", `Float 30.0) ] in 195 let result = to_string value in 196 let trimmed = String.trim result in 197 Alcotest.(check bool) 198 "contains name" true 199 (starts_with "name" trimmed || starts_with "\"name\"" trimmed) 200 201let test_roundtrip_simple () = 202 let yaml = "name: Alice" in 203 let value = of_string yaml in 204 let _ = to_string value in 205 (* Just check it doesn't crash *) 206 () 207 208let test_roundtrip_sequence () = 209 let yaml = "- one\n- two\n- three" in 210 let value = of_string yaml in 211 match value with 212 | `A items when List.length items = 3 -> 213 let _ = to_string value in 214 () 215 | _ -> Alcotest.fail "roundtrip failed" 216 217let emitter_tests = 218 [ 219 ("emit null", `Quick, test_emit_null); 220 ("emit mapping", `Quick, test_emit_mapping); 221 ("roundtrip simple", `Quick, test_roundtrip_simple); 222 ("roundtrip sequence", `Quick, test_roundtrip_sequence); 223 ] 224 225(** YAML-specific tests *) 226 227let test_yaml_anchor () = 228 let yaml = "&anchor hello" in 229 let result = yaml_of_string yaml in 230 match result with 231 | `Scalar s when Scalar.anchor s = Some "anchor" -> () 232 | _ -> Alcotest.fail "expected scalar with anchor" 233 234let test_yaml_alias () = 235 let yaml = 236 {| 237defaults: &defaults 238 timeout: 30 239production: 240 <<: *defaults 241 port: 8080 242|} 243 in 244 (* Just check it parses without error *) 245 let _ = yaml_of_string yaml in 246 () 247 248let yaml_tests = 249 [ 250 ("yaml anchor", `Quick, test_yaml_anchor); 251 ("yaml alias", `Quick, test_yaml_alias); 252 ] 253 254(** Multiline scalar tests *) 255 256let test_literal_block () = 257 let yaml = {|description: | 258 This is a 259 multi-line 260 description 261|} in 262 let result = of_string yaml in 263 match result with 264 | `O [ ("description", `String _) ] -> () 265 | _ -> Alcotest.fail "expected mapping with literal block" 266 267let test_folded_block () = 268 let yaml = {|description: > 269 This is a 270 folded 271 description 272|} in 273 let result = of_string yaml in 274 match result with 275 | `O [ ("description", `String _) ] -> () 276 | _ -> Alcotest.fail "expected mapping with folded block" 277 278let multiline_tests = 279 [ 280 ("literal block", `Quick, test_literal_block); 281 ("folded block", `Quick, test_folded_block); 282 ] 283 284(** Error handling tests *) 285 286let test_error_position () = 287 try 288 let _ = of_string "key: [unclosed" in 289 Alcotest.fail "expected error" 290 with Yamlrw_error e -> Alcotest.(check bool) "has span" true (e.span <> None) 291 292let error_tests = [ ("error position", `Quick, test_error_position) ] 293 294(** Alias expansion limit tests (billion laughs protection) *) 295 296let test_node_limit () = 297 (* Small bomb that would expand to 9^4 = 6561 nodes *) 298 let yaml = 299 {| 300a: &a [1,2,3,4,5,6,7,8,9] 301b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] 302c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] 303d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] 304|} 305 in 306 (* Should fail with a small node limit *) 307 try 308 let _ = of_string ~max_nodes:100 yaml in 309 Alcotest.fail "expected node limit error" 310 with Yamlrw_error e -> ( 311 match e.Error.kind with 312 | Error.Alias_expansion_node_limit _ -> () 313 | _ -> Alcotest.fail "expected Alias_expansion_node_limit error") 314 315let test_depth_limit () = 316 (* Create deeply nested alias chain: 317 *e -> [*d,*d] -> [*c,*c] -> [*b,*b] -> [*a,*a] -> [x,y,z] 318 Each alias resolution increases depth by 1 *) 319 let yaml = 320 {| 321a: &a [x, y, z] 322b: &b [*a, *a] 323c: &c [*b, *b] 324d: &d [*c, *c] 325e: &e [*d, *d] 326result: *e 327|} 328 in 329 (* Should fail with a small depth limit (depth 3 means max 3 alias hops) *) 330 try 331 let _ = of_string ~max_depth:3 yaml in 332 Alcotest.fail "expected depth limit error" 333 with Yamlrw_error e -> ( 334 match e.Error.kind with 335 | Error.Alias_expansion_depth_limit _ -> () 336 | _ -> 337 Alcotest.fail 338 ("expected Alias_expansion_depth_limit error, got: " 339 ^ Error.kind_to_string e.Error.kind)) 340 341let test_normal_aliases_work () = 342 (* Normal alias usage should work fine *) 343 let yaml = 344 {| 345defaults: &defaults 346 timeout: 30 347 retries: 3 348production: 349 <<: *defaults 350 port: 8080 351|} 352 in 353 let result = of_string yaml in 354 match result with `O _ -> () | _ -> Alcotest.fail "expected mapping" 355 356let test_resolve_aliases_false () = 357 (* With resolve_aliases=false, aliases should remain unresolved *) 358 let yaml = {| 359a: &anchor value 360b: *anchor 361|} in 362 let result = yaml_of_string ~resolve_aliases:false yaml in 363 (* Check that alias is preserved *) 364 match result with 365 | `O map -> ( 366 let pairs = Mapping.members map in 367 match List.assoc_opt (`Scalar (Scalar.make "b")) pairs with 368 | Some (`Alias "anchor") -> () 369 | _ -> Alcotest.fail "expected alias to be preserved") 370 | _ -> Alcotest.fail "expected mapping" 371 372let alias_limit_tests = 373 [ 374 ("node limit", `Quick, test_node_limit); 375 ("depth limit", `Quick, test_depth_limit); 376 ("normal aliases work", `Quick, test_normal_aliases_work); 377 ("resolve_aliases false", `Quick, test_resolve_aliases_false); 378 ] 379 380(** Bug fix regression tests 381 These tests verify that issues fixed in ocaml-yaml don't occur in ocaml-yamlrw *) 382 383(* Test for roundtrip of special string values (ocaml-yaml fix 225387d) 384 Strings like "true", "1.0", "null" etc. must be quoted on output so that 385 they round-trip correctly as strings, not as booleans/numbers/null *) 386let test_roundtrip_string_true () = 387 let original = `String "true" in 388 let emitted = to_string original in 389 let parsed = of_string emitted in 390 check_value "String 'true' roundtrips" original parsed 391 392let test_roundtrip_string_false () = 393 let original = `String "false" in 394 let emitted = to_string original in 395 let parsed = of_string emitted in 396 check_value "String 'false' roundtrips" original parsed 397 398let test_roundtrip_string_null () = 399 let original = `String "null" in 400 let emitted = to_string original in 401 let parsed = of_string emitted in 402 check_value "String 'null' roundtrips" original parsed 403 404let test_roundtrip_string_number () = 405 let original = `String "1.0" in 406 let emitted = to_string original in 407 let parsed = of_string emitted in 408 check_value "String '1.0' roundtrips" original parsed 409 410let test_roundtrip_string_integer () = 411 let original = `String "42" in 412 let emitted = to_string original in 413 let parsed = of_string emitted in 414 check_value "String '42' roundtrips" original parsed 415 416let test_roundtrip_string_yes () = 417 let original = `String "yes" in 418 let emitted = to_string original in 419 let parsed = of_string emitted in 420 check_value "String 'yes' roundtrips" original parsed 421 422let test_roundtrip_string_no () = 423 let original = `String "no" in 424 let emitted = to_string original in 425 let parsed = of_string emitted in 426 check_value "String 'no' roundtrips" original parsed 427 428(* Test for integer display without decimal point (ocaml-yaml fix 999b1aa) 429 Float values that are integers should be emitted as "42" not "42." or "42.0" *) 430let test_emit_integer_float () = 431 let value = `Float 42.0 in 432 let result = to_string value in 433 (* Check the result doesn't contain "42." or "42.0" *) 434 Alcotest.(check bool) "no trailing dot" 435 true (not (String.length result >= 3 && 436 result.[0] = '4' && result.[1] = '2' && result.[2] = '.')) 437 438let test_emit_negative_integer_float () = 439 let value = `Float (-17.0) in 440 let result = to_string value in 441 let parsed = of_string result in 442 check_value "negative integer float roundtrips" value parsed 443 444(* Test for special YAML floats: .nan, .inf, -.inf *) 445let test_parse_special_floats () = 446 let inf_result = of_string ".inf" in 447 (match inf_result with 448 | `Float f when Float.is_infinite f && f > 0.0 -> () 449 | _ -> Alcotest.fail "expected positive infinity"); 450 let neg_inf_result = of_string "-.inf" in 451 (match neg_inf_result with 452 | `Float f when Float.is_infinite f && f < 0.0 -> () 453 | _ -> Alcotest.fail "expected negative infinity"); 454 let nan_result = of_string ".nan" in 455 (match nan_result with 456 | `Float f when Float.is_nan f -> () 457 | _ -> Alcotest.fail "expected NaN") 458 459(* Test that bare "inf", "nan", "infinity" are NOT parsed as floats 460 (ocaml-yaml issue - OCaml's Float.of_string accepts these but YAML doesn't) *) 461let test_bare_inf_nan_are_strings () = 462 let inf_result = of_string "inf" in 463 (match inf_result with 464 | `String "inf" -> () 465 | `Float _ -> Alcotest.fail "'inf' should be string, not float" 466 | _ -> Alcotest.fail "expected string 'inf'"); 467 let nan_result = of_string "nan" in 468 (match nan_result with 469 | `String "nan" -> () 470 | `Float _ -> Alcotest.fail "'nan' should be string, not float" 471 | _ -> Alcotest.fail "expected string 'nan'"); 472 let infinity_result = of_string "infinity" in 473 (match infinity_result with 474 | `String "infinity" -> () 475 | `Float _ -> Alcotest.fail "'infinity' should be string, not float" 476 | _ -> Alcotest.fail "expected string 'infinity'") 477 478(* Test for quoted scalar preservation *) 479let test_quoted_scalar_preserved () = 480 (* When a scalar is quoted, it should be preserved as a string even if 481 it looks like a number/boolean *) 482 check_value "double-quoted true is string" 483 (`String "true") (of_string {|"true"|}); 484 check_value "single-quoted 42 is string" 485 (`String "42") (of_string "'42'"); 486 check_value "double-quoted null is string" 487 (`String "null") (of_string {|"null"|}) 488 489(* Test complex roundtrip with mixed types *) 490let test_complex_roundtrip () = 491 let original = `O [ 492 ("string_true", `String "true"); 493 ("bool_true", `Bool true); 494 ("string_42", `String "42"); 495 ("int_42", `Float 42.0); 496 ("string_null", `String "null"); 497 ("actual_null", `Null); 498 ] in 499 let emitted = to_string original in 500 let parsed = of_string emitted in 501 check_value "complex roundtrip preserves types" original parsed 502 503let bugfix_regression_tests = [ 504 "roundtrip string 'true'", `Quick, test_roundtrip_string_true; 505 "roundtrip string 'false'", `Quick, test_roundtrip_string_false; 506 "roundtrip string 'null'", `Quick, test_roundtrip_string_null; 507 "roundtrip string '1.0'", `Quick, test_roundtrip_string_number; 508 "roundtrip string '42'", `Quick, test_roundtrip_string_integer; 509 "roundtrip string 'yes'", `Quick, test_roundtrip_string_yes; 510 "roundtrip string 'no'", `Quick, test_roundtrip_string_no; 511 "emit integer float without decimal", `Quick, test_emit_integer_float; 512 "emit negative integer float", `Quick, test_emit_negative_integer_float; 513 "parse special floats (.inf, -.inf, .nan)", `Quick, test_parse_special_floats; 514 "bare inf/nan/infinity are strings", `Quick, test_bare_inf_nan_are_strings; 515 "quoted scalars preserved as strings", `Quick, test_quoted_scalar_preserved; 516 "complex roundtrip preserves types", `Quick, test_complex_roundtrip; 517] 518 519(** Run all tests *) 520 521let () = 522 Alcotest.run "yamlrw" 523 [ 524 ("scanner", scanner_tests); 525 ("parser", parser_tests); 526 ("value", value_tests); 527 ("emitter", emitter_tests); 528 ("yaml", yaml_tests); 529 ("multiline", multiline_tests); 530 ("errors", error_tests); 531 ("alias_limits", alias_limit_tests); 532 ("bugfix_regression", bugfix_regression_tests); 533 ]