-module(glazer_yaml).
-moduledoc """
Fast YAML encoding and decoding using the glaze C++ library.
By default `null`s are represented as the atom `null`. To change it
application-wide, set the `null` env key in your config:
```
{glazer, [{null, nil}]}.
```
## Features
- Decoding YAML mappings/sequences/scalars to Erlang maps/lists/scalars,
including big integers
- Encoding Erlang terms to YAML in block style
- Configurable representation of YAML `null` and mapping keys, with
optional YAML 1.1 boolean compatibility (`yes`/`no`/`on`/`off`)
- `read_file/1,2` and `write_file/2,3` helpers for decoding/encoding
directly to/from a file
## Example
```erlang
1> glazer_yaml:decode(<<"a: 1\nb:\n - true\n - null\n - 3.5\n">>).
#{<<"a">> => 1, <<"b">> => [true, null, 3.5]}
2> glazer_yaml:encode(#{<<"a">> => 1, <<"b">> => [true, null, 3.5]}).
<<"a: 1\nb:\n - true\n - null\n - 3.5\n">>
```
See also [https://github.com/stephenberry/glaze]
""".
-export([decode/1, decode/2, try_decode/1, try_decode/2,
encode/1, encode/2,
read_file/1, read_file/2, write_file/2, write_file/3]).
-type decode_opt() ::
use_nil
| {null_term, atom()}
| {keys, atom | existing_atom | binary}
| yaml_1_1_bools
| copy_strings.
-doc """
YAML decode options:
- `use_nil` - use the atom `nil` for YAML `null`/`~`/empty values
- `{null_term, Atom}` - use `Atom` for YAML `null`/`~`/empty values
- `{keys, atom}` - decode mapping keys as atoms
- `{keys, existing_atom}` - decode mapping keys as existing atoms, fall back to binary
- `{keys, binary}` - decode mapping keys as binaries (default)
- `yaml_1_1_bools` - additionally treat `yes`/`no`/`on`/`off` (and case
variants) as booleans, per the YAML 1.1 core schema. By default (YAML 1.2
core schema) only `true`/`false` are recognized as booleans.
- `copy_strings` - always allocate a fresh binary for each decoded
scalar, rather than returning a sub-binary that references the original
input. By default (without this option) single-line plain scalars are
zero-copy sub-binaries of the input, which is faster but keeps the entire
input binary alive in memory as long as any decoded scalar referencing it
is reachable. Use `copy_strings` when decoded scalars are long-lived and
the input is large, to allow the GC to reclaim the input buffer
independently.
""".
-type decode_opts() :: [decode_opt()].
-type encode_opt() ::
use_nil
| {null_term, atom()}.
-doc """
YAML encode options:
- `use_nil` - treat the atom `nil` as YAML `null`
- `{null_term, Atom}` - treat `Atom` as YAML `null`
""".
-type encode_opts() :: [encode_opt()].
-export_type([decode_opt/0, decode_opts/0, encode_opt/0, encode_opts/0]).
-doc """
Decode a YAML binary or iolist to an Erlang term. YAML mappings are returned
as maps (default). Raises `{parse_error, Reason}` on invalid input.
""".
-spec decode(binary() | iolist()) -> term().
decode(Input) ->
case try_decode(Input) of
{ok, Term} -> Term;
{error, Reason} -> error(Reason)
end.
-doc """
Decode a YAML binary or iolist to an Erlang term with options.
Raises `{parse_error, Msg}` on invalid input.
## Example
```erlang
1> glazer_yaml:decode(<<"a: ~\n">>, [use_nil]).
#{<<"a">> => nil}
2> glazer_yaml:decode(<<"a: 1\n">>, [{keys, atom}]).
#{a => 1}
3> glazer_yaml:decode(<<"a: yes\n">>, [yaml_1_1_bools]).
#{<<"a">> => true}
```
""".
-spec decode(binary() | iolist(), decode_opts()) -> term().
decode(Input, Opts) ->
case try_decode(Input, Opts) of
{ok, Term} -> Term;
{error, Reason} -> error({parse_error, Reason})
end.
-doc """
Decode a YAML binary or iolist, returning `{ok, Term}` or
`{error, Msg}` instead of raising.
""".
-spec try_decode(binary() | iolist()) -> {ok, term()} | {error, binary()}.
try_decode(Input) ->
glazer:yaml_try_decode(Input).
-doc """
Decode a YAML binary or iolist with options, returning `{ok, Term}` or
`{error, Msg}` instead of raising.
""".
-spec try_decode(binary() | iolist(), decode_opts()) -> {ok, term()} | {error, binary()}.
try_decode(Input, Opts) ->
glazer:yaml_try_decode(Input, Opts).
-doc """
Encode an Erlang term to a YAML binary in block style (2-space indentation,
sequences at the same indentation as the mapping key that owns them).
Raises `{encode_error, {Msg, Term}}` if `Data` contains a value that
cannot be represented as YAML (e.g. an improper list, a pid, or a tuple
that is not a `{[{K,V},...]}` proplist).
## Examples
```erlang
1> glazer_yaml:encode(#{<<"a">> => 1, <<"b">> => [true, null, 3.5]}).
<<"a: 1\nb:\n - true\n - null\n - 3.5\n">>
2> glazer_yaml:encode([1|2]).
** exception error: {encode_error,{<<"cannot encode improper list as sequence">>,2}}
```
""".
-spec encode(term()) -> binary().
encode(Data) ->
glazer:yaml_encode(Data).
-doc """
Encode an Erlang term to a YAML binary in block style with options.
Raises `{encode_error, {Msg, Term}}` if `Data` contains a value that
cannot be represented as YAML.
## Example
```erlang
1> glazer_yaml:encode(#{<<"a">> => nil}, [use_nil]).
<<"a: null\n">>
```
""".
-spec encode(term(), encode_opts()) -> binary().
encode(Data, Opts) ->
glazer:yaml_encode(Data, Opts).
-doc """
Read `Filename` and decode its contents as YAML.
Raises `{parse_error, Reason}` if the file's contents aren't valid YAML, or
a binary `"Filename: Reason"` message (see `file:format_error/1`) if the
file can't be read.
## Example
```erlang
1> glazer_yaml:read_file("data.yaml").
#{<<"a">> => 1}
```
""".
-spec read_file(file:name_all()) -> term().
read_file(Filename) ->
read_file(Filename, []).
-doc """
Read `Filename` and decode its contents as YAML, with decode options
(see `decode/2`).
""".
-spec read_file(file:name_all(), decode_opts()) -> term().
read_file(Filename, Opts) ->
case file:read_file(Filename) of
{ok, Bin} -> decode(Bin, Opts);
{error, Reason} -> error(glazer:format_error("~ts: ~ts", [Filename,file:format_error(Reason)]))
end.
-doc """
Encode `Data` to YAML and write it to `Filename`, overwriting any existing
file.
Raises a binary `"Filename: Reason"` message (see `file:format_error/1`)
if the file can't be written.
## Example
```erlang
1> glazer_yaml:write_file("data.yaml", #{<<"a">> => 1}).
ok
```
""".
-spec write_file(file:name_all(), term()) -> ok.
write_file(Filename, Data) ->
write_file(Filename, Data, []).
-doc """
Encode `Data` to YAML with encode options (see `encode/2`) and write it to
`Filename`, overwriting any existing file.
""".
-spec write_file(file:name_all(), term(), encode_opts()) -> ok.
write_file(Filename, Data, Opts) ->
case file:write_file(Filename, encode(Data, Opts)) of
ok -> ok;
{error, Reason} -> error(glazer:format_error("~ts: ~ts", [Filename, file:format_error(Reason)]))
end.