fig-sys 3.0.3

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
Documentation
//! Number-lexeme rendering shared by the printers.
//!
//! A number's `raw` is the source lexeme — the payload IS the value (`ast.zig`)
//! — so a printer's instinct is to write it back verbatim. That is only correct
//! when the target format reads the lexeme back as the *same number*. It often
//! doesn't: YAML 1.2 resolves `0b1010` to a **string**, and JSON5 rejects
//! `0o755` outright. Writing raw there emits a silent type change, or output
//! the target cannot parse at all.
//!
//! So a printer asks `spellable(raw, <target>)` first, and falls back to
//! `writeCanonical` — decimal, the one spelling every format shares. This is a
//! *spelling* degrade: the value is unchanged, only its notation.

const std = @import("std");

/// The number spellings a target format reads back as a number. A `false` field
/// means the printer must canonicalize that spelling to decimal instead.
pub const Spelling = struct {
    /// `0x1F`
    hex: bool = false,
    /// `0o17`
    octal: bool = false,
    /// `0b1010`
    binary: bool = false,
    /// `1_000` — digit separators
    underscores: bool = false,
    /// `0755` — a multi-digit integer part led by a zero
    leading_zero: bool = false,
    /// `.5` / `5.`
    bare_dot: bool = false,
    /// a leading `+`
    plus: bool = false,
};

/// Strict JSON: none of it. Every non-decimal spelling canonicalizes.
pub const json: Spelling = .{};

/// JSON5 numbers are ES5.1 `NumericLiteral`: hex, leading `+`, and bare dots —
/// but NOT `0o`/`0b` (ES6), `_` (ES2021), or a leading zero. Verified against
/// this repo's own JSON5 tokenizer, which rejects all four.
pub const json5: Spelling = .{ .hex = true, .bare_dot = true, .plus = true };

/// YAML 1.2 core: int is `[-+]?[0-9]+ | 0o[0-7]+ | 0x[0-9a-fA-F]+`, float takes
/// a bare dot. No `0b` and no `_` — 1.2 resolves both to a *string*.
///
/// 1.1's extra spellings (`0b`, `_`, base-60) are deliberately NOT claimed: the
/// YAML printer has no version parameter, so it targets the default type
/// (`yaml.zig`: `default_type = .v1_2_2`). `leading_zero` IS claimed because
/// 1.2 reads `0755` as decimal 755 — note 1.1 would read it as octal 493, an
/// ambiguity the AST cannot represent (the parser stores the lexeme and leaves
/// resolution to the consumer), so it is left alone rather than guessed at.
pub const yaml_1_2: Spelling = .{
    .hex = true,
    .octal = true,
    .leading_zero = true,
    .bare_dot = true,
    .plus = true,
};

/// Whether a format that can spell `s` reads `raw` back as the same number.
pub fn spellable(raw: []const u8, s: Spelling) bool {
    var body = raw;
    if (body.len > 0 and (body[0] == '+' or body[0] == '-')) {
        if (body[0] == '+' and !s.plus) return false;
        body = body[1..];
    }
    if (body.len == 0) return false;
    if (!s.underscores and std.mem.indexOfScalar(u8, body, '_') != null) return false;

    if (body.len >= 2 and body[0] == '0') switch (body[1] | 0x20) {
        'x' => return s.hex,
        'o' => return s.octal,
        'b' => return s.binary,
        else => {},
    };

    // Decimal from here.
    if (!s.bare_dot and (body[0] == '.' or body[body.len - 1] == '.')) return false;
    if (!s.leading_zero) {
        const int_end = std.mem.indexOfAny(u8, body, ".eE") orelse body.len;
        if (int_end > 1 and body[0] == '0') return false;
    }
    return true;
}

/// Write `raw` verbatim when a format spelling `s` reads it back unchanged,
/// else canonicalized to decimal.
pub fn write(writer: anytype, raw: []const u8, s: Spelling) !void {
    if (spellable(raw, s)) return writer.writeAll(raw);
    return writeCanonical(writer, raw);
}

/// Write `raw` as a decimal lexeme every format can read: radix converted, `_`
/// and `+` dropped, bare dots padded (`.5` -> `0.5`, `5.` -> `5.0`), leading
/// zeros stripped. Plain decimal digits are copied rather than re-formatted, so
/// arbitrary precision and significant figures survive (`1.10` stays `1.10`).
pub fn writeCanonical(writer: anytype, raw: []const u8) !void {
    var s = raw;
    if (s.len > 0 and s[0] == '-') {
        try writer.writeByte('-');
        s = s[1..];
    } else if (s.len > 0 and s[0] == '+') {
        s = s[1..];
    }

    // Radix integers convert; a lexeme too long or too wide for u128 falls back
    // to the sign-stripped source rather than emitting nothing.
    if (s.len >= 2 and s[0] == '0' and (s[1] | 0x20 == 'x' or s[1] | 0x20 == 'o' or s[1] | 0x20 == 'b')) {
        const base: u8 = switch (s[1] | 0x20) {
            'x' => 16,
            'o' => 8,
            else => 2,
        };
        var buf: [128]u8 = undefined;
        if (stripUnderscores(s[2..], &buf)) |digits| {
            if (std.fmt.parseInt(u128, digits, base)) |v| {
                try writer.print("{d}", .{v});
                return;
            } else |_| {}
        }
        try writer.writeAll(s);
        return;
    }

    const e_idx = std.mem.indexOfAny(u8, s, "eE");
    const mantissa = if (e_idx) |i| s[0..i] else s;
    const exponent = if (e_idx) |i| s[i..] else "";
    const dot = std.mem.indexOfScalar(u8, mantissa, '.');

    var int_digits: usize = 0;
    var seen_nonzero = false;
    for (if (dot) |d| mantissa[0..d] else mantissa) |c| {
        if (c == '_') continue;
        if (c == '0' and !seen_nonzero) continue; // leading zero
        seen_nonzero = true;
        try writer.writeByte(c);
        int_digits += 1;
    }
    if (int_digits == 0) try writer.writeByte('0'); // `0`, `000`, `.5`

    if (dot) |d| {
        try writer.writeByte('.');
        var frac_digits: usize = 0;
        for (mantissa[d + 1 ..]) |c| {
            if (c == '_') continue;
            try writer.writeByte(c);
            frac_digits += 1;
        }
        if (frac_digits == 0) try writer.writeByte('0'); // `5.` -> `5.0`
    }

    for (exponent) |c| {
        if (c != '_') try writer.writeByte(c);
    }
}

/// Copy `s` into `buf` without `_` digit separators; null if it would overflow.
fn stripUnderscores(s: []const u8, buf: []u8) ?[]const u8 {
    var n: usize = 0;
    for (s) |c| {
        if (c == '_') continue;
        if (n >= buf.len) return null;
        buf[n] = c;
        n += 1;
    }
    return buf[0..n];
}

test "spellable: strict JSON takes only plain decimal" {
    const t = std.testing;
    try t.expect(spellable("1000", json));
    try t.expect(spellable("-1.5e3", json));
    try t.expect(spellable("1.10", json));
    try t.expect(spellable("0", json));
    try t.expect(spellable("0.5", json));
    try t.expect(!spellable("0xff", json));
    try t.expect(!spellable("0o755", json));
    try t.expect(!spellable("0b1010", json));
    try t.expect(!spellable("1_000", json));
    try t.expect(!spellable("+5", json));
    try t.expect(!spellable(".5", json));
    try t.expect(!spellable("5.", json));
    try t.expect(!spellable("0755", json));
}

test "spellable: JSON5 takes hex but not 0o/0b/_" {
    const t = std.testing;
    try t.expect(spellable("0xff", json5));
    try t.expect(spellable("0XFF", json5));
    try t.expect(spellable("+5", json5));
    try t.expect(spellable(".5", json5));
    try t.expect(spellable("5.", json5));
    try t.expect(!spellable("0o755", json5));
    try t.expect(!spellable("0b1010", json5));
    try t.expect(!spellable("0xf_f", json5));
    try t.expect(!spellable("1_000", json5));
    try t.expect(!spellable("0755", json5));
}

test "spellable: YAML 1.2 takes hex and 0o but not 0b/_" {
    const t = std.testing;
    try t.expect(spellable("0xff", yaml_1_2));
    try t.expect(spellable("0o755", yaml_1_2));
    try t.expect(spellable("0755", yaml_1_2)); // 1.2 reads decimal 755
    try t.expect(spellable("1.5e3", yaml_1_2));
    try t.expect(!spellable("0b1010", yaml_1_2)); // resolves to a string
    try t.expect(!spellable("1_000", yaml_1_2)); // resolves to a string
    try t.expect(!spellable("0xdead_beef", yaml_1_2));
}

test "writeCanonical" {
    const t = std.testing;
    var buf: [64]u8 = undefined;
    const canon = struct {
        fn f(b: []u8, raw: []const u8) ![]const u8 {
            var w = std.Io.Writer.fixed(b);
            try writeCanonical(&w, raw);
            return w.buffered();
        }
    }.f;

    // Radix -> decimal, separators dropped.
    try t.expectEqualStrings("255", try canon(&buf, "0xff"));
    try t.expectEqualStrings("3735928559", try canon(&buf, "0xdead_beef"));
    try t.expectEqualStrings("493", try canon(&buf, "0o755"));
    try t.expectEqualStrings("10", try canon(&buf, "0b1010"));
    try t.expectEqualStrings("-255", try canon(&buf, "-0xf_f"));
    try t.expectEqualStrings("1000", try canon(&buf, "1_000"));

    // Bare dots padded; `+` dropped; leading zeros stripped.
    try t.expectEqualStrings("0.5", try canon(&buf, ".5"));
    try t.expectEqualStrings("5.0", try canon(&buf, "5."));
    try t.expectEqualStrings("15", try canon(&buf, "+15"));
    try t.expectEqualStrings("755", try canon(&buf, "0755"));
    try t.expectEqualStrings("0", try canon(&buf, "0"));
    try t.expectEqualStrings("0", try canon(&buf, "000"));
    try t.expectEqualStrings("0.5", try canon(&buf, "00.5"));

    // Significant figures and precision survive — never re-formatted.
    try t.expectEqualStrings("1.10", try canon(&buf, "1.10"));
    try t.expectEqualStrings("1e2", try canon(&buf, "1e2"));
    try t.expectEqualStrings("10.5", try canon(&buf, "1_0.5"));
    try t.expectEqualStrings(
        "123456789012345678901234567890",
        try canon(&buf, "123456789012345678901234567890"),
    );
}