fig 2.0.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! Shared RFC-3339-style date/time validation and classification.
//!
//! TOML datetimes and YAML 1.1 `!!timestamp` scalars share the same lexical
//! core — `YYYY-MM-DD`, an optional `T`/`t`/space separator, `HH:MM:SS[.frac]`,
//! and a `Z`/`±HH:MM` zone — so the validation lives here once. Callers map the
//! returned `Kind` onto their own scalar type (both use the AST `ExtKind`, whose
//! first four members line up with `Kind`); this module stays AST-free so it
//! remains a leaf utility.
//!
//! `classify` is total: it returns `error.InvalidDatetime` for anything that is
//! not a well-formed datetime. That lets a parser use it two ways — to validate
//! a token it already believes is a datetime (TOML), or to *probe* an arbitrary
//! plain scalar and treat the error as "not a timestamp" (YAML). Because of the
//! probe use, every field is digit-checked here rather than trusting a tokenizer
//! to have done it.

const std = @import("std");
const ascii = @import("ascii.zig");

pub const Error = error{InvalidDatetime};

/// Which datetime shape a string resolved to. Mirrors the first four members of
/// the AST `ExtKind`, kept independent so this leaf utility doesn't depend on
/// the AST.
pub const Kind = enum { offset_datetime, local_datetime, local_date, local_time };

pub const Options = struct {
    /// Accept `HH:MM` with the seconds omitted. TOML 1.0 requires seconds (pass
    /// false); TOML 1.1 and YAML are lenient.
    allow_minute_precision: bool = true,
    /// Accept a bare `HH:MM:SS[.frac]` with no date as a `local_time`. TOML
    /// allows it; YAML 1.1 does not (there a `:`-run with no date is a
    /// sexagesimal number), so YAML passes false.
    allow_time_only: bool = true,
};

pub fn classify(raw: []const u8, opts: Options) Error!Kind {
    // Time-only: HH:MM...
    if (opts.allow_time_only and raw.len >= 3 and raw[2] == ':') {
        try validateTime(raw, opts);
        return .local_time;
    }
    // Date present: YYYY-MM-DD.
    if (raw.len < 10) return error.InvalidDatetime;
    try validateDate(raw[0..10]);
    if (raw.len == 10) return .local_date;

    // Separator, then time (+ optional zone).
    const sep = raw[10];
    if (sep != 'T' and sep != 't' and sep != ' ') return error.InvalidDatetime;
    const rest = raw[11..];

    // Zone: trailing Z/z, or ±HH:MM at the end.
    var time_str = rest;
    var has_offset = false;
    if (rest.len > 0 and (rest[rest.len - 1] == 'Z' or rest[rest.len - 1] == 'z')) {
        time_str = rest[0 .. rest.len - 1];
        has_offset = true;
    } else if (rest.len >= 6 and (rest[rest.len - 6] == '+' or rest[rest.len - 6] == '-') and rest[rest.len - 3] == ':') {
        try validateOffset(rest[rest.len - 6 ..]);
        time_str = rest[0 .. rest.len - 6];
        has_offset = true;
    }
    try validateTime(time_str, opts);
    return if (has_offset) .offset_datetime else .local_datetime;
}

fn twoDigit(s: []const u8, at: usize) u8 {
    return (s[at] - '0') * 10 + (s[at + 1] - '0');
}

/// Both bytes at `s[at..at+2]` are ASCII digits.
fn bothDigits(s: []const u8, at: usize) bool {
    return at + 1 < s.len and ascii.isDigit(s[at]) and ascii.isDigit(s[at + 1]);
}

pub fn validateDate(s: []const u8) Error!void {
    if (s.len != 10 or s[4] != '-' or s[7] != '-') return error.InvalidDatetime;
    if (!bothDigits(s, 0) or !bothDigits(s, 2) or !bothDigits(s, 5) or !bothDigits(s, 8)) return error.InvalidDatetime;
    const year = @as(u16, twoDigit(s, 0)) * 100 + twoDigit(s, 2);
    const month = twoDigit(s, 5);
    const day = twoDigit(s, 8);
    if (month < 1 or month > 12) return error.InvalidDatetime;
    if (day < 1 or day > daysInMonth(year, month)) return error.InvalidDatetime;
}

fn daysInMonth(year: u16, month: u8) u8 {
    return switch (month) {
        1, 3, 5, 7, 8, 10, 12 => 31,
        4, 6, 9, 11 => 30,
        2 => if (isLeapYear(year)) @as(u8, 29) else 28,
        else => 0,
    };
}

fn isLeapYear(year: u16) bool {
    return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0;
}

/// `HH:MM[:SS[.fraction]]`; seconds 00-60 (leap second allowed). With
/// `allow_minute_precision` false the seconds are mandatory.
pub fn validateTime(s: []const u8, opts: Options) Error!void {
    if (s.len < 5 or s[2] != ':') return error.InvalidDatetime;
    if (!bothDigits(s, 0) or !bothDigits(s, 3)) return error.InvalidDatetime;
    if (twoDigit(s, 0) > 23 or twoDigit(s, 3) > 59) return error.InvalidDatetime;
    if (s.len == 5) {
        if (!opts.allow_minute_precision) return error.InvalidDatetime;
        return;
    }
    if (s[5] != ':' or s.len < 8) return error.InvalidDatetime;
    if (!bothDigits(s, 6) or twoDigit(s, 6) > 60) return error.InvalidDatetime;
    if (s.len == 8) return;
    if (s[8] != '.' or s.len < 10) return error.InvalidDatetime;
    for (s[9..]) |c| if (!ascii.isDigit(c)) return error.InvalidDatetime;
}

/// `±HH:MM` zone offset; hour 00-23, minute 00-59.
pub fn validateOffset(s: []const u8) Error!void {
    if (s.len != 6 or s[3] != ':') return error.InvalidDatetime;
    if (!bothDigits(s, 1) or !bothDigits(s, 4)) return error.InvalidDatetime;
    if (twoDigit(s, 1) > 23 or twoDigit(s, 4) > 59) return error.InvalidDatetime;
}

test "classify shapes" {
    const t = std.testing;
    const cases = [_]struct { src: []const u8, kind: Kind }{
        .{ .src = "1979-05-27T07:32:00Z", .kind = .offset_datetime },
        .{ .src = "1979-05-27T07:32:00.999-07:00", .kind = .offset_datetime },
        .{ .src = "1979-05-27T07:32:00", .kind = .local_datetime },
        .{ .src = "1979-05-27 07:32:00", .kind = .local_datetime },
        .{ .src = "1979-05-27", .kind = .local_date },
        .{ .src = "07:32:00", .kind = .local_time },
        .{ .src = "00:32:00.999999", .kind = .local_time },
    };
    for (cases) |c| try t.expectEqual(c.kind, try classify(c.src, .{}));
}

test "rejects malformed datetimes" {
    const t = std.testing;
    const bad = [_][]const u8{ "", "hello", "2002-13-01", "2002-02-30", "20A2-12-14", "1979-05-27X07:32:00", "1979-05-27T07:60:00", "2002-12-14T12:30" };
    for (bad) |s| try t.expectError(error.InvalidDatetime, classify(s, .{ .allow_minute_precision = false }));
}

test "options gate time-only and minute precision" {
    const t = std.testing;
    // Time-only refused when disallowed (YAML).
    try t.expectError(error.InvalidDatetime, classify("07:32:00", .{ .allow_time_only = false }));
    // Minute precision refused when disallowed (TOML 1.0).
    try t.expectError(error.InvalidDatetime, classify("1979-05-27T07:32", .{ .allow_minute_precision = false }));
    try t.expectEqual(Kind.local_datetime, try classify("1979-05-27T07:32", .{ .allow_minute_precision = true }));
}