Skip to main content

src/glazer_yaml.erl

-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.