Current section
Files
Jump to
Current section
Files
src/taffy.gleam
//// A pure-Gleam YAML 1.2 parser. Passes 351/351 of the official YAML test
//// suite. Works on both Erlang and JavaScript targets; an optional native
//// backend (`taffy/native`, Erlang only) wraps `fast_yaml` for ~3-7×
//// speedup on large documents.
////
//// ## Quick start
////
//// ```gleam
//// import taffy
////
//// pub fn main() {
//// let assert Ok(value) =
//// taffy.parse("name: John\nage: 30\ntags:\n - gleam\n - erlang")
////
//// let assert Ok(name) = taffy.get(value, "name")
//// let json = taffy.to_json_string(value)
//// }
//// ```
////
//// ## Supported YAML features
////
//// - Scalars: strings, numbers (incl. `0x`, `0o`, `.inf`, `.nan`),
//// booleans, null
//// - Block + flow collections: `- item`, `key: value`, `[a, b]`,
//// `{k: v}`
//// - Multi-line strings: `|` (literal) and `>` (folded)
//// - Anchors and aliases: `&anchor` and `*alias`
//// - Merge keys: `<<: *anchor` and `<<: [*a, *b]`, resolved automatically
//// - Multi-document streams via `parse_all`
//// - Comments, document markers (`---`, `...`), tag directives
////
//// ## Output
////
//// - `to_json` / `to_json_string` — convert to `gleam_json` values
//// - `to_yaml` — emit block-style YAML, round-trips through `parse`
//// - `validate_unique_keys` — opt-in YAML 1.2 strict duplicate-key check
import gleam/dict.{type Dict}
import gleam/float
import gleam/int
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option}
import gleam/result
import gleam/string
import taffy/lexer
import taffy/parser
import taffy/parser/types.{type ParseError, ParseError}
import taffy/value.{type YamlValue}
pub type Value =
YamlValue
pub type Error =
ParseError
/// Compute `#(line, column)` (both 1-indexed) for a grapheme position
/// inside `input`. Out-of-range positions clamp to the last line/column.
/// Useful for turning `error.pos` into a user-facing location:
///
/// ```gleam
/// case taffy.parse(input) {
/// Ok(_) -> ...
/// Error(err) -> {
/// let #(line, col) = taffy.error_location(input, err.pos)
/// io.println(err.message <> " at " <> int.to_string(line) <>
/// ":" <> int.to_string(col))
/// }
/// }
/// ```
pub fn error_location(input: String, pos: Int) -> #(Int, Int) {
error_location_loop(string.to_graphemes(input), pos, 1, 1)
}
fn error_location_loop(
graphemes: List(String),
remaining: Int,
line: Int,
col: Int,
) -> #(Int, Int) {
case graphemes, remaining {
_, n if n <= 0 -> #(line, col)
[], _ -> #(line, col)
["\n", ..rest], _ -> error_location_loop(rest, remaining - 1, line + 1, 1)
[_, ..rest], _ -> error_location_loop(rest, remaining - 1, line, col + 1)
}
}
/// Parse a single YAML document. For multi-document streams use `parse_all`.
/// On error the position points at the grapheme where parsing gave up.
/// `<<` merge keys are resolved automatically. Duplicate mapping keys are
/// allowed (with first-wins access) — the YAML 1.2 spec mandates uniqueness
/// but the official test suite expects parsers to accept them; use
/// `validate_unique_keys` to enforce strict uniqueness yourself.
pub fn parse(input: String) -> Result(Value, Error) {
case lexer.tokenize(input) {
Error(#(msg, pos)) -> Error(ParseError(msg, pos))
Ok(tokens) -> parser.parse(tokens) |> result.map(value.resolve_merges)
}
}
/// Parse a YAML stream as a list of documents. Empty input yields `[]`.
/// Same merge-key + duplicate semantics as `parse`.
pub fn parse_all(input: String) -> Result(List(Value), Error) {
case lexer.tokenize(input) {
Error(#(msg, pos)) -> Error(ParseError(msg, pos))
Ok(tokens) ->
parser.parse_all(tokens) |> result.map(list.map(_, value.resolve_merges))
}
}
/// Reject any mapping (recursively) that contains duplicate keys. The error
/// message names the first offending key. Run after `parse` if your
/// application requires YAML 1.2's key-uniqueness invariant.
pub fn validate_unique_keys(val: Value) -> Result(Value, Error) {
case value.check_no_duplicates(val) {
Ok(Nil) -> Ok(val)
Error(key) -> Error(ParseError("Duplicate mapping key: " <> key, 0))
}
}
/// Look up a key in a mapping. Returns `Error(Nil)` when `val` is not a
/// mapping or the key is absent.
pub fn get(val: Value, key: String) -> Result(Value, Nil) {
value.get(val, key) |> option.to_result(Nil)
}
/// Like `get`, but returns `default` instead of an error when the key is
/// missing or `val` is not a mapping.
pub fn get_or(val: Value, key: String, default: Value) -> Value {
case value.get(val, key) {
option.Some(v) -> v
option.None -> default
}
}
/// Walk a chain of keys through nested mappings. An empty path returns
/// `val` unchanged; any missing key short-circuits to `Error(Nil)`.
pub fn get_path(val: Value, path: List(String)) -> Result(Value, Nil) {
case path {
[] -> Ok(val)
[key, ..rest] -> {
case value.get(val, key) {
option.Some(v) -> get_path(v, rest)
option.None -> Error(Nil)
}
}
}
}
/// Get the `idx`-th item of a sequence (0-indexed). Returns `Error(Nil)` if
/// `val` is not a sequence or `idx` is out of range.
pub fn index(val: Value, idx: Int) -> Result(Value, Nil) {
value.index(val, idx) |> option.to_result(Nil)
}
/// Pull out a string scalar; `None` for any other variant.
pub fn as_string(val: Value) -> Option(String) {
value.as_string(val)
}
/// Pull out an int scalar; `None` for any other variant (no float coercion).
pub fn as_int(val: Value) -> Option(Int) {
value.as_int(val)
}
/// Pull out a float scalar. Coerces `Int(n)` to `Float(n)` so numeric callers
/// don't have to branch on the parse-time tag.
pub fn as_float(val: Value) -> Option(Float) {
value.as_float(val)
}
/// Pull out a bool scalar; `None` for any other variant.
pub fn as_bool(val: Value) -> Option(Bool) {
value.as_bool(val)
}
/// Pull out a sequence as a list. Order is preserved.
pub fn as_list(val: Value) -> Option(List(Value)) {
value.as_list(val)
}
/// Pull out a mapping as a `Dict`. Note: this loses YAML's insertion order;
/// use `as_pairs` if order matters.
pub fn as_dict(val: Value) -> Option(Dict(String, Value)) {
value.as_dict(val)
}
/// Pull out a mapping as ordered `#(key, value)` pairs, preserving YAML
/// insertion order.
pub fn as_pairs(val: Value) -> Option(List(#(String, Value))) {
value.as_pairs(val)
}
/// True only for `Null`. Bool/Int/zero values all return False.
pub fn is_null(val: Value) -> Bool {
value.is_null(val)
}
/// Render a value as a JSON-shaped debug string. Useful for snapshot tests;
/// for round-trippable YAML output use `to_yaml`.
pub fn to_string(val: Value) -> String {
value.to_string(val)
}
/// Emit a value as block-style YAML with a trailing newline. The output
/// round-trips through `parse` for all values that are themselves
/// round-trippable (taffy is lossy on tag, anchor, and comment metadata).
pub fn to_yaml(val: Value) -> String {
value.to_yaml(val)
}
/// Convert a parsed YAML value into a `gleam_json` `Json`. Whole-valued
/// floats serialize as ints to round-trip nicely with the YAML test suite.
pub fn to_json(val: Value) -> Json {
case val {
value.Null -> json.null()
value.Bool(b) -> json.bool(b)
value.Int(i) -> json.int(i)
value.Float(f) -> {
let truncated = float.truncate(f)
case f == int.to_float(truncated) {
True -> json.int(truncated)
False -> json.float(f)
}
}
value.String(s) -> json.string(s)
value.Sequence(items) -> json.array(items, to_json)
value.Mapping(pairs) ->
json.object(
pairs
|> list.map(fn(pair) { #(pair.0, to_json(pair.1)) }),
)
}
}
/// Convenience wrapper around `to_json` + `json.to_string`.
pub fn to_json_string(val: Value) -> String {
to_json(val) |> json.to_string
}