diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0b3c74b..829cf3f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -46,9 +46,25 @@ jobs: # The binding code only exists under the feature, so a bare clippy # run never looks at src/python.rs. cargo clippy --features python --all-targets -- -D warnings + cargo clippy --features capi --all-targets -- -D warnings - name: Rust tests - run: cargo test + run: | + cargo test + cargo test --features capi + + # The header and the LuaJIT cdef are checked against the library by the + # Rust tests; this checks that a C compiler agrees, which they cannot. + - name: C ABI + run: | + apt-get update && apt-get install -y --no-install-recommends gcc luajit + cargo build --release --features capi + cc -Wall -Wextra -Werror -Iinclude examples/c/demo.c \ + -Ltarget/release -ltypikon -o /tmp/demo + LD_LIBRARY_PATH=target/release /tmp/demo rocor + LD_LIBRARY_PATH=target/release /tmp/demo --list + TYPIKON_LIB=target/release/libtypikon.so \ + luajit examples/lua/statusline.lua rocor # The CLI and the bindings are the same engine, so they have to agree. This # is the check that caught the bindings resolving their own default diff --git a/CHANGELOG.md b/CHANGELOG.md index bc823f3..a4d0b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out. **Calendar** entries change what a day reports, whatever the version number does. +## Unreleased + +### Added + +- A C ABI, behind the `capi` feature, with a header in `include/typikon.h`. + Dates cross it as Julian Day Numbers, so no struct layout is part of the + contract. A day is opaque and owns its strings: an accessor returns the same + borrowed pointer every time and allocates nothing, so reading one in a render + loop is free. The accessors carry every field of the JSON, so a statusline + does not need a parser. Errors go to a thread-local `typikon_last_error()`. +- A LuaJIT FFI binding in `examples/lua/typikon.lua`, for AwesomeWM, WezTerm + and Neovim, and a worked C example in `examples/c/demo.c`. +- `jurisdictions::resolve_full`, which resolves a jurisdiction together with + the usage and the discipline. The Python bindings and the C ABI now share it + rather than each keeping a copy, and it interns the combinations instead of + leaking one per call. + ## 1.0.0 First stable release. The public surface is now the one described in diff --git a/Cargo.toml b/Cargo.toml index d6c6fbe..de62a33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,6 @@ crate-type = ["cdylib", "rlib"] # symbols to be resolved by the interpreter at load time, so a normal `cargo # build` of the CLI would fail to link if it were on by default. python = ["dep:pyo3"] +# The C ABI. Off by default so an ordinary `cargo build` of the CLI does not +# also compile and link a shared library nobody asked for. +capi = [] diff --git a/README.md b/README.md index 956d047..df98ab9 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,57 @@ the kind of disagreement nobody notices until a fast day is wrong. The wheel is abi3, so one build serves every Python from 3.9 up. It ships `py.typed` and a stub file, and `mypy --strict` is clean against it. +## From C, and anything with an FFI + +```sh +cargo build --release --features capi # target/release/libtypikon.so +cc -Iinclude yours.c -ltypikon +``` + +```c +#include + +typikon_day *d = typikon_describe(typikon_today(), "rocor"); +if (!d) { fputs(typikon_last_error(), stderr); return 1; } + +typikon_day_church_date(d); /* "5 September" */ +typikon_day_fast(d); /* "xerophagy" */ +typikon_day_emoji(d); /* the glyph for the level */ +typikon_day_title(d); /* the feast, else the principal commemoration */ +typikon_day_is_obligatory(d); /* 0 */ + +typikon_day_free(d); +``` + +Dates cross the boundary as **Julian Day Numbers**, so no struct layout is part +of the ABI and there is nothing for a caller to keep in step; +`typikon_jdn_from_ymd` converts. A day is opaque and owns its strings: an +accessor returns the same borrowed pointer every time, so reading one in a +render loop allocates nothing, and nothing it returns should be freed. NULL +from an accessor means the day has no such field, which is not an error. A call +that can fail returns NULL or 0 and leaves a message in `typikon_last_error()`. + +[`include/typikon.h`](https://git.pumpin.online/funkpower/typikon/src/branch/main/include/typikon.h) +is the whole surface: it carries every field of the JSON, so a statusline does +not need a parser. [`examples/c/demo.c`](https://git.pumpin.online/funkpower/typikon/src/branch/main/examples/c/demo.c) +is a working one. + +### From Lua + +[`examples/lua/typikon.lua`](https://git.pumpin.online/funkpower/typikon/src/branch/main/examples/lua/typikon.lua) +is a LuaJIT FFI binding, for AwesomeWM, WezTerm, Neovim and anything else that +embeds LuaJIT and should not be spawning a process on every redraw. + +```lua +local typikon = require("typikon") + +local day = assert(typikon.today({ jurisdiction = "rocor" })) +day.church_date -- "5 September" +day.fast -- "xerophagy" +day.title -- "The Holy Prophet Zachariah, father of the Forerunner" +day.commemorations +``` + ## The menologion The fixed calendar ships as data and is indexed by **ecclesiastical** date, so diff --git a/examples/c/demo.c b/examples/c/demo.c new file mode 100644 index 0000000..1234bf6 --- /dev/null +++ b/examples/c/demo.c @@ -0,0 +1,86 @@ +/* Build: + * cargo build --release --features capi + * cc -Iinclude examples/c/demo.c -Ltarget/release -ltypikon -o /tmp/demo + * LD_LIBRARY_PATH=target/release /tmp/demo rocor + * + * With no arguments it uses the remembered default; with --list it prints + * every jurisdiction it knows. + */ +#include +#include +#include + +static void show(int32_t jdn, const char *jurisdiction) { + typikon_day *d = typikon_describe(jdn, jurisdiction); + if (!d) { + fprintf(stderr, " %s\n", typikon_last_error()); + return; + } + const char *season = typikon_day_season(d); + const char *sign = typikon_day_sign(d); + printf(" %-12s %-14s %-4s %-28s %s%s%s\n", + jurisdiction ? jurisdiction : "(default)", + typikon_day_church_date(d), + typikon_day_emoji(d), + typikon_day_fast_label(d), + sign ? sign : "", + typikon_day_title(d), + typikon_day_is_obligatory(d) ? " [obligatory]" : ""); + if (season) printf(" season: %s\n", season); + /* The title is the feast, or the first commemoration where there is no + feast, so start past it rather than printing it twice. */ + uint32_t start = typikon_day_feast(d) ? 0 : 1; + for (uint32_t i = start; i < typikon_day_commemoration_count(d); i++) + printf(" also %s\n", typikon_day_commemoration(d, i)); + typikon_day_free(d); +} + +static void list(void) { + for (uint32_t i = 0; i < typikon_jurisdiction_count(); i++) + printf(" %-18s %s\n", typikon_jurisdiction_key(i), + typikon_jurisdiction_name(i)); +} + +int main(int argc, char **argv) { + printf("typikon %s, ABI %u\n\n", typikon_version(), typikon_abi_version()); + + if (argc == 2 && strcmp(argv[1], "--list") == 0) { + list(); + return 0; + } + + int32_t today = typikon_today(); + for (int i = 1; i < argc; i++) show(today, argv[i]); + if (argc == 1) show(today, NULL); + + /* A day with something to say: the first day of the Great Fast, 2026, + under Byzantine Catholic law and then at the Typikon's discipline. */ + int32_t clean_monday = typikon_jdn_from_ymd(2026, 2, 16); + printf("\n Clean Monday 2026:\n"); + typikon_day *law = typikon_describe_full(clean_monday, "ruthenian", NULL, 0); + typikon_day *fast = typikon_describe_full(clean_monday, "ruthenian", NULL, 1); + printf(" law %-30s binds %s\n", + typikon_day_fast_label(law), + typikon_day_binds(law) ? typikon_day_binds(law) : "nothing"); + printf(" typikon %-30s binds %s\n", + typikon_day_fast_label(fast), + typikon_day_binds(fast) ? typikon_day_binds(fast) : "nothing"); + typikon_day_free(law); + typikon_day_free(fast); + + /* Pascha without reckoning a day for it. */ + int32_t p = typikon_pascha(2026, "old"); + int32_t y, dd; + uint32_t m, d2; + typikon_ymd_from_jdn(p, &y, &m, &d2); + dd = p - typikon_today(); + printf("\n Pascha 2026 (Julian paschalion): %04d-%02u-%02u, %d days from today\n", + y, m, d2, dd); + + /* Errors do not crash. */ + if (!typikon_describe(today, "atlantis")) + printf("\n bad jurisdiction: %s\n", typikon_last_error()); + if (!typikon_jdn_from_ymd(2026, 2, 30)) + printf(" bad date : %s\n", typikon_last_error()); + return 0; +} diff --git a/examples/lua/statusline.lua b/examples/lua/statusline.lua new file mode 100644 index 0000000..5163349 --- /dev/null +++ b/examples/lua/statusline.lua @@ -0,0 +1,40 @@ +-- What a statusline would do with it: one line, no subprocess, no JSON. +-- +-- cargo build --release --features capi +-- TYPIKON_LIB=target/release/libtypikon.so \ +-- luajit examples/lua/statusline.lua rocor + +package.path = (arg[0]:match("(.*/)") or "./") .. "?.lua;" .. package.path + +local typikon = require("typikon") + +local day, e = typikon.today({ jurisdiction = arg[1] }) +if not day then + io.stderr:write(e, "\n") + os.exit(1) +end + +-- The one line. +print(("%s %s %s%s"):format( + day.emoji, + day.church_date, + day.sign and (day.sign .. " ") or "", + day.title)) + +-- And the detail a tooltip would carry. +print() +print((" %-14s %s (%s, %s)"):format("reckoning", day.jurisdiction, day.calendar, day.tradition)) +print((" %-14s %s%s"):format("fast", day.fast_label, + day.is_obligatory and " [obligatory]" or "")) +if day.binds then + print((" %-14s %s"):format("law requires", day.binds)) +end +print((" %-14s %s"):format("because", day.reason)) +if day.season then print((" %-14s %s"):format("season", day.season)) end +if day.cycle then print((" %-14s %s"):format("cycle", day.cycle)) end +for i = 2, #day.commemorations do + print((" %-14s %s"):format(i == 2 and "also" or "", day.commemorations[i])) +end + +print((" %-14s %d days (%s)"):format("to Pascha", day.days_to_pascha, + ("%04d-%02d-%02d"):format(typikon.ymd(day.pascha_jdn)))) diff --git a/examples/lua/typikon.lua b/examples/lua/typikon.lua new file mode 100644 index 0000000..d406133 --- /dev/null +++ b/examples/lua/typikon.lua @@ -0,0 +1,168 @@ +-- typikon, through LuaJIT's FFI. +-- +-- Drop this beside your config and `local typikon = require("typikon")`. It +-- needs libtypikon.so on the loader's path, or TYPIKON_LIB set to it: +-- +-- cargo build --release --features capi +-- TYPIKON_LIB=target/release/libtypikon.so luajit examples/lua/statusline.lua +-- +-- Written for AwesomeWM, WezTerm and Neovim, which all embed LuaJIT and none +-- of which should be spawning a process on every redraw. + +local ffi = require("ffi") + +ffi.cdef [[ +const char *typikon_version(void); +uint32_t typikon_abi_version(void); +const char *typikon_last_error(void); + +int32_t typikon_jdn_from_ymd(int32_t year, uint32_t month, uint32_t day); +int typikon_ymd_from_jdn(int32_t jdn, int32_t *year, uint32_t *month, uint32_t *day); +int32_t typikon_today(void); + +typedef struct typikon_day typikon_day; +typikon_day *typikon_describe(int32_t jdn, const char *jurisdiction); +typikon_day *typikon_describe_full(int32_t jdn, const char *jurisdiction, + const char *tradition, int discipline); +void typikon_day_free(typikon_day *day); + +const char *typikon_day_json(const typikon_day *day); +const char *typikon_day_iso(const typikon_day *day); +const char *typikon_day_weekday(const typikon_day *day); +const char *typikon_day_jurisdiction(const typikon_day *day); +const char *typikon_day_calendar(const typikon_day *day); +const char *typikon_day_tradition(const typikon_day *day); +const char *typikon_day_church_date(const typikon_day *day); +const char *typikon_day_title(const typikon_day *day); +const char *typikon_day_fast(const typikon_day *day); +const char *typikon_day_fast_label(const typikon_day *day); +const char *typikon_day_emoji(const typikon_day *day); +const char *typikon_day_reason(const typikon_day *day); +const char *typikon_day_season(const typikon_day *day); +const char *typikon_day_cycle(const typikon_day *day); +const char *typikon_day_feast(const typikon_day *day); +const char *typikon_day_rank(const typikon_day *day); +const char *typikon_day_sign(const typikon_day *day); +const char *typikon_day_obligation(const typikon_day *day); +const char *typikon_day_binds(const typikon_day *day); +uint32_t typikon_day_commemoration_count(const typikon_day *day); +const char *typikon_day_commemoration(const typikon_day *day, uint32_t i); +int typikon_day_is_obligatory(const typikon_day *day); +int32_t typikon_day_days_to_pascha(const typikon_day *day); +int32_t typikon_day_pascha(const typikon_day *day); +int32_t typikon_day_julian_offset(const typikon_day *day); + +int32_t typikon_pascha(int32_t year, const char *jurisdiction); +uint32_t typikon_jurisdiction_count(void); +const char *typikon_jurisdiction_key(uint32_t i); +const char *typikon_jurisdiction_name(uint32_t i); +const char *typikon_jurisdiction_note(uint32_t i); +]] + +local C = ffi.load(os.getenv("TYPIKON_LIB") or "typikon") + +local M = { ABI = 1 } + +-- A string field, or nil where the library returned NULL. Every accessor may, +-- and NULL means "the day has no such thing" rather than an error. +local function str(p) + if p == nil then return nil end + return ffi.string(p) +end + +local function err() + return str(C.typikon_last_error()) or "unknown error" +end + +-- The fields worth reading without a JSON parser. Order matters only for the +-- table's own readability. +local FIELDS = { + "iso", "weekday", "jurisdiction", "calendar", "tradition", "church_date", + "title", "fast", "fast_label", "emoji", "reason", + "season", "cycle", "feast", "rank", "sign", "obligation", "binds", +} + +-- Copy the day into a plain Lua table and free it. The C day owns its strings +-- and they die with it, so nothing here may hold on to a pointer. +local function harvest(d) + local t = {} + for _, f in ipairs(FIELDS) do + t[f] = str(C["typikon_day_" .. f](d)) + end + t.is_obligatory = C.typikon_day_is_obligatory(d) ~= 0 + t.days_to_pascha = C.typikon_day_days_to_pascha(d) + t.pascha_jdn = C.typikon_day_pascha(d) + t.julian_offset = C.typikon_day_julian_offset(d) + t.commemorations = {} + for i = 0, C.typikon_day_commemoration_count(d) - 1 do + t.commemorations[i + 1] = str(C.typikon_day_commemoration(d, i)) + end + t.json = str(C.typikon_day_json(d)) + C.typikon_day_free(d) + return t +end + +--- One day, as a table. `opts` may carry `jurisdiction`, `tradition` and +--- `typikon` (true to show the day at the Typikon's full discipline, false for +--- only what particular law requires, nil for whatever was remembered). +--- Returns nil and a message on error. +function M.describe(jdn, opts) + opts = opts or {} + local discipline = -1 + if opts.typikon == true then discipline = 1 + elseif opts.typikon == false then discipline = 0 end + local d = C.typikon_describe_full(jdn, opts.jurisdiction, opts.tradition, discipline) + if d == nil then return nil, err() end + return harvest(d) +end + +--- Today, by local time. +function M.today(opts) + return M.describe(C.typikon_today(), opts) +end + +--- The Julian Day Number for a civil date, or nil and a message. +function M.jdn(year, month, day) + local n = C.typikon_jdn_from_ymd(year, month, day) + if n == 0 then return nil, err() end + return n +end + +--- The civil year, month and day for a Julian Day Number. +function M.ymd(jdn) + local y = ffi.new("int32_t[1]") + local m = ffi.new("uint32_t[1]") + local d = ffi.new("uint32_t[1]") + if C.typikon_ymd_from_jdn(jdn, y, m, d) ~= 0 then return nil, err() end + return y[0], tonumber(m[0]), tonumber(d[0]) +end + +--- Pascha in a civil year, as a Julian Day Number. +function M.pascha(year, jurisdiction) + local n = C.typikon_pascha(year, jurisdiction) + if n == 0 then return nil, err() end + return n +end + +--- Every jurisdiction the library knows: { key, name, note }. +function M.jurisdictions() + local out = {} + for i = 0, C.typikon_jurisdiction_count() - 1 do + out[i + 1] = { + key = str(C.typikon_jurisdiction_key(i)), + name = str(C.typikon_jurisdiction_name(i)), + note = str(C.typikon_jurisdiction_note(i)), + } + end + return out +end + +M.version = str(C.typikon_version()) + +-- A library newer in a way that changed meaning, not one merely newer. +if C.typikon_abi_version() ~= M.ABI then + error(("typikon ABI %d, this binding wants %d") + :format(tonumber(C.typikon_abi_version()), M.ABI)) +end + +return M diff --git a/include/typikon.h b/include/typikon.h new file mode 100644 index 0000000..02d6fb7 --- /dev/null +++ b/include/typikon.h @@ -0,0 +1,152 @@ +/* typikon - the Orthodox liturgical day, computed rather than fetched. + * + * SPDX-License-Identifier: GPL-2.0-only + * https://git.pumpin.online/funkpower/typikon + * + * Link against libtypikon.so, built with: + * + * cargo build --release --features capi + * + * Dates cross this boundary as Julian Day Numbers, so no struct layout is part + * of the ABI. typikon_jdn_from_ymd converts. + * + * A typikon_day is opaque and owns its strings. They are built once when the + * day is reckoned, so an accessor returns the same borrowed, NUL-terminated + * pointer every time and allocates nothing: calling one in a render loop is + * free. A pointer is valid until typikon_day_free, or NULL where the day has + * no such field. Nothing an accessor returns should be freed. + * + * A call that can fail returns NULL or 0 and leaves a message in + * typikon_last_error(), which is thread-local. + * + * Every function is safe to call from any thread. A typikon_day is not + * synchronised; do not share one between threads without your own lock. + */ + +#ifndef TYPIKON_H +#define TYPIKON_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- version ---------------------------------------------------------- */ + +/* A dotted version string. Static storage; never freed. */ +const char *typikon_version(void); + +/* Bumped when anything here changes meaning or signature. Additions do not + * bump it. Compare against TYPIKON_ABI_VERSION if you load at runtime. */ +uint32_t typikon_abi_version(void); +#define TYPIKON_ABI_VERSION 1u + +/* The message from the last failing call on this thread, or NULL if the last + * call succeeded. Valid until the next failing call on this thread. */ +const char *typikon_last_error(void); + +/* ---- dates ------------------------------------------------------------ */ + +/* Julian Day Number for a civil (Gregorian) date, or 0 if there is no such + * date or it falls outside the years 1 to 9999. */ +int32_t typikon_jdn_from_ymd(int32_t year, uint32_t month, uint32_t day); + +/* Civil date for a Julian Day Number. Returns 0 on success, -1 if the number + * is outside the supported years. Any out-param may be NULL. */ +int typikon_ymd_from_jdn(int32_t jdn, int32_t *year, uint32_t *month, + uint32_t *day); + +/* Today's Julian Day Number, in local time. */ +int32_t typikon_today(void); + +/* ---- a day ------------------------------------------------------------ */ + +typedef struct typikon_day typikon_day; + +/* Reckon a day. jurisdiction may be NULL to use whatever + * `typikon --set-default` remembered. NULL on error. Free with + * typikon_day_free. */ +typikon_day *typikon_describe(int32_t jdn, const char *jurisdiction); + +/* As typikon_describe, overriding the usage and the discipline. + * + * tradition "slavic", "greek", or NULL for the jurisdiction's own. + * discipline 1 show the day at the Typikon's full discipline + * 0 show only what the church's particular law requires + * -1 use whatever was remembered + * + * The distinction matters for the Byzantine Catholic churches, whose law + * requires very much less than the tradition they keep. */ +typikon_day *typikon_describe_full(int32_t jdn, const char *jurisdiction, + const char *tradition, int discipline); + +/* NULL is accepted and ignored. */ +void typikon_day_free(typikon_day *day); + +/* ---- reading a day ---------------------------------------------------- */ + +/* The whole day as JSON: the same document the command line emits, under the + * same stability promise. Use this if you have a JSON parser to hand; the + * accessors below carry every field of it, so that a statusline does not need + * one. */ +const char *typikon_day_json(const typikon_day *day); + +/* Never NULL for a live day. */ +const char *typikon_day_iso(const typikon_day *day); /* "2026-04-12" */ +const char *typikon_day_weekday(const typikon_day *day); /* in English */ +const char *typikon_day_jurisdiction(const typikon_day *day); /* key of the church whose reckoning this is */ +const char *typikon_day_calendar(const typikon_day *day); /* old|new|gregorian */ +const char *typikon_day_tradition(const typikon_day *day); /* slavic|greek */ +const char *typikon_day_church_date(const typikon_day *day); /* "25 December" */ +const char *typikon_day_title(const typikon_day *day); /* feast, else principal commemoration */ +const char *typikon_day_fast(const typikon_day *day); /* strict|xerophagy|wine_oil|fish|dairy|none */ +const char *typikon_day_fast_label(const typikon_day *day); /* as this tradition words it */ +const char *typikon_day_emoji(const typikon_day *day); /* glyph for the level */ +const char *typikon_day_reason(const typikon_day *day); /* why the day fasts as it does */ + +/* NULL where the day has no such thing, which is not an error. */ +const char *typikon_day_season(const typikon_day *day); /* NULL in ordinary time */ +const char *typikon_day_cycle(const typikon_day *day); /* NULL outside the movable cycle */ +const char *typikon_day_feast(const typikon_day *day); /* NULL if the day has none */ +const char *typikon_day_rank(const typikon_day *day); /* NULL if the day has no feast */ +const char *typikon_day_sign(const typikon_day *day); /* the mark the books print, else NULL */ +const char *typikon_day_obligation(const typikon_day *day); /* obligatory|customary, else NULL */ +const char *typikon_day_binds(const typikon_day *day); /* NULL where nothing binds */ + +/* The saints and events kept on the day, in the order the books give them. */ +uint32_t typikon_day_commemoration_count(const typikon_day *day); +const char *typikon_day_commemoration(const typikon_day *day, uint32_t i); + +/* Whether the day binds under the church's own particular law. */ +int typikon_day_is_obligatory(const typikon_day *day); + +/* Signed, to the Pascha of this day's own civil year. Negative once it has + * passed: 31 December reports about -263, not the days until the next one. */ +int32_t typikon_day_days_to_pascha(const typikon_day *day); + +/* Julian Day Number of Pascha in this day's civil year. */ +int32_t typikon_day_pascha(const typikon_day *day); + +/* How far the Julian reckoning lags the Gregorian: 13 until 14 March 2100. */ +int32_t typikon_day_julian_offset(const typikon_day *day); + +/* ---- without a day ---------------------------------------------------- */ + +/* Julian Day Number of Pascha in a civil year, by a jurisdiction's + * paschalion. jurisdiction may be NULL. 0 on error. */ +int32_t typikon_pascha(int32_t year, const char *jurisdiction); + +/* Every jurisdiction this build knows: the named churches first, then the + * three bare calendars. The key is what typikon_describe takes. Static + * storage; NULL past the end. */ +uint32_t typikon_jurisdiction_count(void); +const char *typikon_jurisdiction_key(uint32_t i); +const char *typikon_jurisdiction_name(uint32_t i); +const char *typikon_jurisdiction_note(uint32_t i); + +#ifdef __cplusplus +} +#endif + +#endif /* TYPIKON_H */ diff --git a/src/capi.rs b/src/capi.rs new file mode 100644 index 0000000..355a4ac --- /dev/null +++ b/src/capi.rs @@ -0,0 +1,487 @@ +//! A C ABI, so this can be embedded rather than spawned. +//! +//! Every desktop shell that consumes typikon today runs the binary and reads +//! its JSON, which costs about 1.5ms and is the right answer for a calendar +//! that changes once a day. This exists for the callers that cannot spawn, or +//! for whom a subprocess per redraw is the wrong shape: a LuaJIT statusline, a +//! C++ panel, anything with an FFI. +//! +//! # Shape +//! +//! Dates cross the boundary as **Julian Day Numbers**, so no struct layout is +//! part of the ABI and there is nothing to keep in step. `typikon_jdn_from_ymd` +//! converts. +//! +//! A `typikon_day` is opaque and owns its strings. Every accessor returns a +//! borrowed, NUL-terminated pointer valid until `typikon_day_free`, or NULL +//! where the day has no such field. The full JSON is available too, and it is +//! the same document the command line emits, under the same stability promise. +//! +//! # Errors +//! +//! A function that can fail returns NULL or 0 and leaves a message in +//! `typikon_last_error`, which is thread-local and valid until the next failing +//! call on the same thread. +//! +//! # Threads +//! +//! Every function is safe to call from any thread. A `typikon_day` is not +//! synchronised: do not use one from two threads at once. + +use std::cell::RefCell; +use std::ffi::{c_char, c_int, CStr, CString}; +use std::ptr; +use std::sync::OnceLock; + +use crate::calendars::Date; +use crate::day::{self, Day}; +use crate::fasting::Obligation; +use crate::jurisdictions; + +/// Bumped when the meaning or signature of anything here changes. Additions +/// leave it alone. +const ABI_VERSION: u32 = 1; + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_error(msg: impl Into>) { + let cs = CString::new(msg).unwrap_or_else(|_| c"error".into()); + LAST_ERROR.with(|e| *e.borrow_mut() = Some(cs)); +} + +fn clear_error() { + LAST_ERROR.with(|e| *e.borrow_mut() = None); +} + +/// The message from the last failing call on this thread, or NULL if the last +/// call succeeded. Valid until the next failing call on this thread. +#[no_mangle] +pub extern "C" fn typikon_last_error() -> *const c_char { + LAST_ERROR.with(|e| match &*e.borrow() { + Some(s) => s.as_ptr(), + None => ptr::null(), + }) +} + +/// The crate version, as a dotted string. Static; never freed. +#[no_mangle] +pub extern "C" fn typikon_version() -> *const c_char { + concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char +} + +/// The ABI version of this header's contract. +#[no_mangle] +pub extern "C" fn typikon_abi_version() -> u32 { + ABI_VERSION +} + +/// # Safety +/// `p` must be NULL or a valid NUL-terminated C string. +unsafe fn opt_str<'a>(p: *const c_char) -> Option<&'a str> { + if p.is_null() { + return None; + } + unsafe { CStr::from_ptr(p) }.to_str().ok() +} + +// ───────────────────────────────── dates ────────────────────────────────── + +/// The Julian Day Number for a civil (Gregorian) date, or 0 if there is no +/// such date or it falls outside the years this calendar answers for. +#[no_mangle] +pub extern "C" fn typikon_jdn_from_ymd(year: i32, month: u32, day: u32) -> i32 { + match Date::from_ymd(year, month, day) { + Some(d) => { + clear_error(); + d.jdn() + } + None => { + set_error(format!("{year:04}-{month:02}-{day:02} is not a date this calendar reaches")); + 0 + } + } +} + +/// The civil date for a Julian Day Number. Returns 0 on success and -1 if the +/// number is outside the supported years, in which case the out-params are +/// untouched. Any out-param may be NULL. +/// +/// # Safety +/// `year`, `month` and `day` must each be NULL or point to writable storage. +#[no_mangle] +pub unsafe extern "C" fn typikon_ymd_from_jdn( + jdn: i32, + year: *mut i32, + month: *mut u32, + day: *mut u32, +) -> c_int { + let Some(d) = Date::from_jdn(jdn) else { + set_error(format!("julian day {jdn} is outside the supported years")); + return -1; + }; + clear_error(); + let (y, m, dd) = d.ymd(); + unsafe { + if !year.is_null() { + *year = y; + } + if !month.is_null() { + *month = m; + } + if !day.is_null() { + *day = dd; + } + } + 0 +} + +/// Today's Julian Day Number, in local time. +#[no_mangle] +pub extern "C" fn typikon_today() -> i32 { + clear_error(); + crate::local_today().jdn() +} + +// ───────────────────────────────── a day ────────────────────────────────── + +/// One reckoned day. Opaque: its layout is not part of the ABI. +/// +/// Named in C's style rather than Rust's, because the name is what appears in +/// the header and a caller writing C should not have to translate. +#[allow(non_camel_case_types)] +pub struct typikon_day { + inner: Day, + /// Every string the accessors hand out, built once when the day is + /// reckoned. An earlier version allocated on each call and kept the + /// allocation alive until free, so a statusline polling `title` in a loop + /// grew without bound. + json: CString, + iso: CString, + weekday: CString, + jurisdiction: CString, + calendar: CString, + tradition: CString, + church_date: CString, + title: CString, + fast: CString, + fast_label: CString, + emoji: CString, + reason: CString, + season: Option, + cycle: Option, + feast: Option, + rank: Option, + sign: Option, + obligation: Option, + binds: Option, + commemorations: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn build( + jdn: i32, + jurisdiction: Option<&str>, + tradition: Option<&str>, + discipline: c_int, +) -> Option> { + let Some(date) = Date::from_jdn(jdn) else { + set_error(format!("julian day {jdn} is outside the supported years")); + return None; + }; + + // -1 asks for whatever the config remembers; 0 and 1 override it. + let typikon = match discipline { + d if d < 0 => None, + 0 => Some(false), + _ => Some(true), + }; + let jur = match jurisdictions::resolve_full(jurisdiction, tradition, typikon) { + Ok(j) => j, + Err(e) => { + set_error(e); + return None; + } + }; + + clear_error(); + let inner = day::describe(date, jur); + let cs = |s: &str| CString::new(s).unwrap_or_default(); + Some(Box::new(typikon_day { + json: cs(&serde_json::to_string(&inner.to_json()).ok()?), + iso: cs(&inner.date.iso()), + weekday: cs(inner.weekday.name()), + jurisdiction: cs(inner.jurisdiction), + calendar: cs(inner.calendar), + tradition: cs(inner.tradition), + church_date: cs(&inner.church_date), + title: cs(inner.title()), + fast: cs(inner.fast.key()), + fast_label: cs(inner.fast_label), + emoji: cs(inner.emoji), + reason: cs(&inner.reason), + season: inner.season.map(|s| cs(s.name())), + cycle: inner.cycle.as_deref().map(&cs), + feast: inner.feast.as_deref().map(&cs), + rank: inner.rank.map(|r| cs(r.key())), + sign: inner.rank.map(|r| cs(r.sign())), + obligation: inner.obligation.map(|o| { + cs(match o { + Obligation::Obligatory => "obligatory", + Obligation::Customary => "customary", + }) + }), + binds: inner.binds.map(|b| cs(b.key())), + commemorations: inner.commemorations.iter().map(|c| cs(c)).collect(), + inner, + })) +} + +/// Reckon a day. `jurisdiction` may be NULL to use whatever +/// `typikon --set-default` remembered. Returns NULL on error; see +/// `typikon_last_error`. Free it with `typikon_day_free`. +/// +/// # Safety +/// `jurisdiction` must be NULL or a valid NUL-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn typikon_describe( + jdn: i32, + jurisdiction: *const c_char, +) -> *mut typikon_day { + match build(jdn, unsafe { opt_str(jurisdiction) }, None, -1) { + Some(d) => Box::into_raw(d), + None => ptr::null_mut(), + } +} + +/// As `typikon_describe`, overriding the usage and the discipline. +/// +/// `tradition` is "slavic", "greek", or NULL to take the jurisdiction's own. +/// `discipline` is 1 to show the day at the Typikon's full discipline, 0 to +/// show only what the church's particular law requires, and -1 to use whatever +/// was remembered. +/// +/// # Safety +/// `jurisdiction` and `tradition` must each be NULL or a valid NUL-terminated +/// C string. +#[no_mangle] +pub unsafe extern "C" fn typikon_describe_full( + jdn: i32, + jurisdiction: *const c_char, + tradition: *const c_char, + discipline: c_int, +) -> *mut typikon_day { + let (j, t) = unsafe { (opt_str(jurisdiction), opt_str(tradition)) }; + match build(jdn, j, t, discipline) { + Some(d) => Box::into_raw(d), + None => ptr::null_mut(), + } +} + +/// Free a day. NULL is accepted and ignored. +/// +/// # Safety +/// `day` must be NULL or a pointer returned by `typikon_describe` or +/// `typikon_describe_full` and not already freed. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_free(day: *mut typikon_day) { + if !day.is_null() { + drop(unsafe { Box::from_raw(day) }); + } +} + +macro_rules! accessor { + ($name:ident, $doc:literal, |$d:ident| $body:expr) => { + #[doc = $doc] + /// + /// Borrowed and NUL-terminated; valid until `typikon_day_free`. The + /// same pointer is returned every time, so calling this in a loop + /// allocates nothing. + /// + /// # Safety + /// `day` must be NULL or a live pointer from `typikon_describe`. + #[no_mangle] + pub unsafe extern "C" fn $name(day: *const typikon_day) -> *const c_char { + let Some($d) = (unsafe { day.as_ref() }) else { + return ptr::null(); + }; + let value: Option<&CString> = $body; + value.map_or(ptr::null(), |v| v.as_ptr()) + } + }; +} + +accessor!(typikon_day_json, "The whole day as JSON, the same document the command line emits.", |d| Some(&d.json)); +accessor!(typikon_day_iso, "The civil date, as \"2026-04-12\".", |d| Some(&d.iso)); +accessor!(typikon_day_weekday, "The day of the week, in English.", |d| Some(&d.weekday)); +accessor!(typikon_day_jurisdiction, "The key of the church whose reckoning this is, as `typikon_describe` takes it.", |d| Some(&d.jurisdiction)); +accessor!(typikon_day_calendar, "The calendar: old, new or gregorian.", |d| Some(&d.calendar)); +accessor!(typikon_day_tradition, "The usage the day was read under: slavic or greek.", |d| Some(&d.tradition)); +accessor!(typikon_day_church_date, "The ecclesiastical date, as \"25 December\".", |d| Some(&d.church_date)); +accessor!(typikon_day_title, "The feast, or the day's principal commemoration.", |d| Some(&d.title)); +accessor!(typikon_day_fast, "The fasting level: strict, xerophagy, wine_oil, fish, dairy or none.", |d| Some(&d.fast)); +accessor!(typikon_day_fast_label, "The fasting level as this tradition words it.", |d| Some(&d.fast_label)); +accessor!(typikon_day_emoji, "The glyph for the fasting level.", |d| Some(&d.emoji)); +accessor!(typikon_day_reason, "Why the day fasts as it does.", |d| Some(&d.reason)); +accessor!(typikon_day_season, "The season, or NULL in ordinary time.", |d| d.season.as_ref()); +accessor!(typikon_day_cycle, "The place in the movable cycle, or NULL outside it.", |d| d.cycle.as_ref()); +accessor!(typikon_day_feast, "The feast, or NULL if the day has none.", |d| d.feast.as_ref()); +accessor!(typikon_day_rank, "The rank of the feast, or NULL if the day has none.", |d| d.rank.as_ref()); +accessor!(typikon_day_sign, "The mark the books print beside a day of this rank, or NULL.", |d| d.sign.as_ref()); +accessor!(typikon_day_obligation, "obligatory, customary, or NULL where the church does not legislate the day.", |d| d.obligation.as_ref()); +accessor!(typikon_day_binds, "The minimum a particular law requires, or NULL where nothing binds.", |d| d.binds.as_ref()); + +/// Whether the day's fast binds under the church's own particular law. +/// +/// # Safety +/// `day` must be a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_is_obligatory(day: *const typikon_day) -> c_int { + match unsafe { day.as_ref() } { + Some(d) => d.inner.is_obligatory() as c_int, + None => 0, + } +} + +/// Days to Pascha, signed, counting to the Pascha of this day's own civil +/// year. Negative once it has passed. +/// +/// # Safety +/// `day` must be a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_days_to_pascha(day: *const typikon_day) -> i32 { + match unsafe { day.as_ref() } { + Some(d) => d.inner.days_to_pascha, + None => 0, + } +} + +/// The Julian Day Number of Pascha in this day's civil year, or 0 if `day` is +/// NULL. +/// +/// # Safety +/// `day` must be a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_pascha(day: *const typikon_day) -> i32 { + match unsafe { day.as_ref() } { + Some(d) => d.inner.pascha.jdn(), + None => 0, + } +} + +/// How many commemorations the day carries. +/// +/// # Safety +/// `day` must be NULL or a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_commemoration_count(day: *const typikon_day) -> u32 { + match unsafe { day.as_ref() } { + Some(d) => d.commemorations.len() as u32, + None => 0, + } +} + +/// The `i`th commemoration, or NULL if there is no such one. Borrowed, and +/// valid until `typikon_day_free`. +/// +/// # Safety +/// `day` must be NULL or a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_commemoration( + day: *const typikon_day, + i: u32, +) -> *const c_char { + match unsafe { day.as_ref() } { + Some(d) => d + .commemorations + .get(i as usize) + .map_or(ptr::null(), |c| c.as_ptr()), + None => ptr::null(), + } +} + +/// How far the Julian reckoning currently lags the Gregorian one: 13 until 14 +/// March 2100, 14 after it. Reported for every jurisdiction, including those +/// that do not use it. +/// +/// # Safety +/// `day` must be NULL or a live pointer from `typikon_describe`. +#[no_mangle] +pub unsafe extern "C" fn typikon_day_julian_offset(day: *const typikon_day) -> i32 { + match unsafe { day.as_ref() } { + Some(d) => d.inner.julian_offset, + None => 0, + } +} + +/// The Julian Day Number of Pascha in a given civil year, by a given +/// jurisdiction's paschalion. Returns 0 on error. +/// +/// Tradition and discipline cannot move Pascha, so they are not arguments. +/// +/// # Safety +/// `jurisdiction` must be NULL or a valid NUL-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn typikon_pascha(year: i32, jurisdiction: *const c_char) -> i32 { + let jur = match jurisdictions::resolve_full(unsafe { opt_str(jurisdiction) }, None, None) { + Ok(j) => j, + Err(e) => { + set_error(e); + return 0; + } + }; + let (movables, _) = day::year_of(year, jur); + clear_error(); + movables.pascha.jdn() +} + +// ───────────────────────────── jurisdictions ────────────────────────────── + +/// Key, name and note for every jurisdiction, NUL-terminated once and kept. +/// The table is a compile-time constant, so this is built on first use and +/// never changes. +fn jurisdiction_table() -> &'static [(CString, CString, CString)] { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + let cs = |s: &str| CString::new(s).unwrap_or_default(); + jurisdictions::JURISDICTIONS + .iter() + .chain(jurisdictions::CALENDARS) + .map(|j| (cs(j.key), cs(j.name), cs(j.note))) + .collect() + }) +} + +/// How many jurisdictions this build knows. The named churches come first, +/// then the three bare calendars. +#[no_mangle] +pub extern "C" fn typikon_jurisdiction_count() -> u32 { + jurisdiction_table().len() as u32 +} + +/// The `i`th jurisdiction's key, the one `typikon_describe` takes, or NULL if +/// there is no such one. Static storage; never freed. +#[no_mangle] +pub extern "C" fn typikon_jurisdiction_key(i: u32) -> *const c_char { + jurisdiction_table() + .get(i as usize) + .map_or(ptr::null(), |j| j.0.as_ptr()) +} + +/// The `i`th jurisdiction's name, as it would be printed. Static storage. +#[no_mangle] +pub extern "C" fn typikon_jurisdiction_name(i: u32) -> *const c_char { + jurisdiction_table() + .get(i as usize) + .map_or(ptr::null(), |j| j.1.as_ptr()) +} + +/// What is worth knowing about the `i`th jurisdiction's reckoning, which is +/// often the empty string. Static storage. +#[no_mangle] +pub extern "C" fn typikon_jurisdiction_note(i: u32) -> *const c_char { + jurisdiction_table() + .get(i as usize) + .map_or(ptr::null(), |j| j.2.as_ptr()) +} diff --git a/src/jurisdictions.rs b/src/jurisdictions.rs index 40494a1..b995e3c 100644 --- a/src/jurisdictions.rs +++ b/src/jurisdictions.rs @@ -221,3 +221,56 @@ pub fn resolve(name: Option<&str>) -> Result<&'static Jurisdiction, String> { .find(|j| j.key == key) .ok_or_else(|| name.to_string()) } + +/// Resolve a jurisdiction together with the usage and the discipline, the way +/// every caller that is not the command line needs it. +/// +/// Shared, not duplicated. The Python bindings and the C ABI had every reason +/// to answer identically and one earlier copy of this logic read the config +/// file where the CLI read the environment first, so the same machine gave two +/// different churches depending on which door you came in by. +/// +/// `jurisdiction` and `tradition` of `None` fall back to what was remembered; +/// `typikon` of `None` does the same. +/// +/// Overridden combinations are interned rather than leaked per call. The +/// command line leaks one per run and is then done; a library is called in a +/// loop, and one leak per call is a slow leak that shows up as a shell's +/// resident size climbing all day. The set of combinations is bounded by +/// jurisdictions x traditions x discipline, so the map is small and never +/// grows past it. +pub fn resolve_full( + jurisdiction: Option<&str>, + tradition: Option<&str>, + typikon: Option, +) -> Result<&'static Jurisdiction, String> { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + let saved = crate::config::saved(); + let (default_name, _) = crate::config::default_jurisdiction(); + let name = jurisdiction.map(str::to_owned).or(default_name); + let base = resolve(name.as_deref()).map_err(|n| format!("unknown jurisdiction {n:?}"))?; + + let trad = match tradition.map(str::to_owned).or(saved.tradition) { + None => base.tradition, + Some(t) => Tradition::parse(&t) + .ok_or_else(|| format!("unknown tradition {t:?}; try slavic or greek"))?, + }; + let full = typikon.unwrap_or(saved.typikon); + let discipline = if full { Some(RuleKind::Orthodox) } else { base.discipline }; + + if trad == base.tradition && discipline == base.discipline { + return Ok(base); + } + + type Interned = HashMap<(&'static str, &'static str, bool), &'static Jurisdiction>; + static INTERNED: OnceLock> = OnceLock::new(); + let mut map = INTERNED + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "jurisdiction cache poisoned".to_string())?; + Ok(map.entry((base.key, trad.key(), full)).or_insert_with(|| { + Box::leak(Box::new(Jurisdiction { tradition: trad, discipline, ..*base })) + })) +} diff --git a/src/lib.rs b/src/lib.rs index 9f77a2d..dfa2211 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +use crate::calendars::Date; + pub mod calendars; pub mod config; pub mod day; @@ -12,3 +14,45 @@ pub mod seasons; #[cfg(feature = "python")] mod python; + +#[cfg(feature = "capi")] +pub mod capi; + +/// Today, from the system clock, in local time. +/// +/// The local offset comes from `date +%z`, because working it out from +/// /etc/localtime means parsing TZif and this program would rather shell out +/// than carry a time zone database. Where `date` is absent, as in a scratch +/// container, the answer is UTC and it says so on stderr rather than quietly +/// reporting the wrong day either side of midnight. +pub fn local_today() -> Date { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let local = secs + local_offset_secs(); + // Unix epoch is JDN 2440588. The clock cannot reach either bound. + Date::from_jdn(2440588 + local.div_euclid(86400) as i32).unwrap_or(Date::MIN) +} + +/// Seconds east of UTC, or None when it could not be determined. +fn local_offset_secs() -> i64 { + let Ok(out) = std::process::Command::new("date").arg("+%z").output() else { + eprintln!("typikon: `date` is not on PATH; reckoning today in UTC"); + return 0; + }; + let text = String::from_utf8_lossy(&out.stdout); + let s = text.trim(); + // "+HHMM". Checked as bytes so a short or multi-byte reply cannot panic a + // slice, which a str index here would. + let b = s.as_bytes(); + if b.len() == 5 && (b[0] == b'+' || b[0] == b'-') && b[1..].iter().all(u8::is_ascii_digit) { + let h = (s[1..3].parse::()).unwrap_or(0); + let m = (s[3..5].parse::()).unwrap_or(0); + let mag = h * 3600 + m * 60; + return if b[0] == b'-' { -mag } else { mag }; + } + eprintln!("typikon: could not read the local time offset; reckoning today in UTC"); + 0 +} + diff --git a/src/main.rs b/src/main.rs index 2cd881a..2ab4eda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -419,7 +419,7 @@ fn run() -> Result { let start = match &args.date { Some(s) => Date::parse_iso(s) .ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?, - None => today(), + None => typikon::local_today(), }; let mut days = week(start, args.days, jur); if args.no_emoji { @@ -473,44 +473,6 @@ fn run() -> Result { Ok(0) } -/// Today, from the system clock, in local time. -/// -/// The local offset comes from `date +%z`, because working it out from -/// /etc/localtime means parsing TZif and this program would rather shell out -/// than carry a time zone database. Where `date` is absent, as in a scratch -/// container, the answer is UTC and it says so on stderr rather than quietly -/// reporting the wrong day either side of midnight. -fn today() -> Date { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let local = secs + local_offset_secs(); - // Unix epoch is JDN 2440588. The clock cannot reach either bound. - Date::from_jdn(2440588 + local.div_euclid(86400) as i32).unwrap_or(Date::MIN) -} - -/// Seconds east of UTC, or None when it could not be determined. -fn local_offset_secs() -> i64 { - let Ok(out) = std::process::Command::new("date").arg("+%z").output() else { - eprintln!("typikon: `date` is not on PATH; reckoning today in UTC"); - return 0; - }; - let text = String::from_utf8_lossy(&out.stdout); - let s = text.trim(); - // "+HHMM". Checked as bytes so a short or multi-byte reply cannot panic a - // slice, which a str index here would. - let b = s.as_bytes(); - if b.len() == 5 && (b[0] == b'+' || b[0] == b'-') && b[1..].iter().all(u8::is_ascii_digit) { - let h = (s[1..3].parse::()).unwrap_or(0); - let m = (s[3..5].parse::()).unwrap_or(0); - let mag = h * 3600 + m * 60; - return if b[0] == b'-' { -mag } else { mag }; - } - eprintln!("typikon: could not read the local time offset; reckoning today in UTC"); - 0 -} - fn stamp() -> String { let out = std::process::Command::new("date") .args(["-u", "+%Y%m%dT%H%M%SZ"]) diff --git a/src/python.rs b/src/python.rs index 0c9213c..325268b 100644 --- a/src/python.rs +++ b/src/python.rs @@ -14,8 +14,6 @@ use pyo3::exceptions::{PyValueError, PyOSError}; use pyo3::prelude::*; use pyo3::types::{PyDate, PyDict, PyList}; -use std::collections::HashMap; -use std::sync::{Mutex, OnceLock}; use crate::calendars::Date; use crate::day; @@ -44,67 +42,15 @@ fn from_date<'py>(py: Python<'py>, d: Date) -> PyResult> { PyDate::new(py, y, m as u8, day as u8) } -/// jurisdiction key, tradition key, and whether the Typikon's full discipline was -/// asked for — enough to tell one override from another. -type Interned = HashMap<(&'static str, &'static str, bool), &'static Jurisdiction>; - -/// Build the jurisdiction a call should reckon by: the named one, or the saved -/// default, with the saved tradition and discipline applied on top. -/// -/// This mirrors main.rs deliberately and has to keep mirroring it. A Jurisdiction -/// is no longer just a key — it carries a tradition and a discipline, and the CLI -/// reads all three from config before it reckons anything. A binding that resolved -/// only the key would answer a different fast than the command line does on the -/// same machine for the same date: xerophagy on a Lenten Wednesday reads as -/// wine_oil, which is not a cosmetic difference in a tool whose whole job is to -/// say what may be eaten. -/// -/// The overridden jurisdictions are interned rather than leaked per call. main.rs -/// leaks one per run and is then done; a library gets called in a loop, and one -/// leak per call is a slow leak. The set of combinations is bounded by -/// jurisdictions x traditions x discipline, so a map of them is small and never -/// grows past it. +/// The jurisdiction a call should reckon by, resolved the same way the command +/// line resolves it: the named one or the remembered default, with the +/// remembered usage and discipline applied on top. fn resolve( jurisdiction: Option<&str>, tradition: Option<&str>, typikon: Option, ) -> PyResult<&'static Jurisdiction> { - let saved = crate::config::saved(); - - // The environment beats the file, which is the order main.rs uses. Reading - // only the file meant TYPIKON_JURISDICTION moved the command line and left - // the bindings behind, so the two answered for different churches on the - // same machine, which is the exact failure the note above is about. - let (default_name, _) = crate::config::default_jurisdiction(); - let name = jurisdiction.map(str::to_owned).or(default_name); - let base = jurisdictions::resolve(name.as_deref()) - .map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))?; - - let trad = match tradition.map(str::to_owned).or(saved.tradition) { - None => base.tradition, - Some(t) => jurisdictions::Tradition::parse(&t).ok_or_else(|| { - PyValueError::new_err(format!("unknown tradition {t:?}; try slavic or greek")) - })?, - }; - let full = typikon.unwrap_or(saved.typikon); - let discipline = if full { - Some(jurisdictions::RuleKind::Orthodox) - } else { - base.discipline - }; - - if trad == base.tradition && discipline == base.discipline { - return Ok(base); - } - - static INTERNED: OnceLock> = OnceLock::new(); - let mut map = INTERNED - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| PyValueError::new_err("jurisdiction cache poisoned"))?; - Ok(map.entry((base.key, trad.key(), full)).or_insert_with(|| { - Box::leak(Box::new(Jurisdiction { tradition: trad, discipline, ..*base })) - })) + jurisdictions::resolve_full(jurisdiction, tradition, typikon).map_err(PyValueError::new_err) } /// One day, fully reckoned. diff --git a/tests/capi.rs b/tests/capi.rs new file mode 100644 index 0000000..8a3416f --- /dev/null +++ b/tests/capi.rs @@ -0,0 +1,400 @@ +//! The C ABI, exercised as a C caller would use it. +//! +//! These run only under `--features capi`, which is what builds the module. +//! They cover three things a Rust test can check and a C compiler cannot: that +//! the answers match what the crate gives natively, that the pointers an +//! accessor hands out stay put, and that the header in `include/` still +//! describes the library that is actually built. + +#![cfg(feature = "capi")] + +use std::ffi::{c_char, CStr, CString}; +use std::ptr; + +use typikon::capi::*; + +/// Borrow a returned pointer as a `str`, or `None` for NULL. +/// +/// # Safety +/// Only called on pointers these functions returned, which live until free. +fn s<'a>(p: *const c_char) -> Option<&'a str> { + if p.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(p) }.to_str().expect("utf-8")) + } +} + +fn describe(iso: &str, jurisdiction: &str) -> *mut typikon_day { + let (y, m, d) = ( + iso[0..4].parse().unwrap(), + iso[5..7].parse().unwrap(), + iso[8..10].parse().unwrap(), + ); + let jdn = typikon_jdn_from_ymd(y, m, d); + assert_ne!(jdn, 0, "{iso} should be a date"); + let key = CString::new(jurisdiction).unwrap(); + let day = unsafe { typikon_describe(jdn, key.as_ptr()) }; + assert!(!day.is_null(), "{iso} {jurisdiction} should reckon"); + day +} + +// ──────────────────────────────── dates ─────────────────────────────────── + +#[test] +fn jdn_round_trips() { + for (y, m, d) in [(1, 1, 1), (1583, 10, 15), (2026, 4, 12), (9999, 12, 31)] { + let jdn = typikon_jdn_from_ymd(y, m, d); + assert_ne!(jdn, 0, "{y}-{m}-{d}"); + let (mut yy, mut mm, mut dd) = (0i32, 0u32, 0u32); + let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut yy, &mut mm, &mut dd) }; + assert_eq!(rc, 0); + assert_eq!((yy, mm, dd), (y, m, d)); + } +} + +#[test] +fn an_impossible_date_is_an_error_not_a_guess() { + assert_eq!(typikon_jdn_from_ymd(2026, 2, 30), 0); + let msg = s(typikon_last_error()).expect("a message"); + assert!(msg.contains("2026-02-30"), "{msg}"); + + // And the next success clears it, so a caller that only checks the error + // pointer cannot read a stale one. + assert_ne!(typikon_jdn_from_ymd(2026, 2, 28), 0); + assert!(typikon_last_error().is_null()); +} + +#[test] +fn ymd_out_params_are_each_optional() { + let jdn = typikon_jdn_from_ymd(2026, 4, 12); + let mut year = 0i32; + let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut year, ptr::null_mut(), ptr::null_mut()) }; + assert_eq!(rc, 0); + assert_eq!(year, 2026); +} + +#[test] +fn a_julian_day_outside_the_years_is_refused() { + let mut year = 12345i32; + let rc = unsafe { typikon_ymd_from_jdn(-1, &mut year, ptr::null_mut(), ptr::null_mut()) }; + assert_eq!(rc, -1); + assert_eq!(year, 12345, "out-params are untouched on failure"); + assert!(s(typikon_last_error()).is_some()); +} + +// ───────────────────────────────── a day ────────────────────────────────── + +#[test] +fn a_day_answers_what_the_crate_answers() { + let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap(); + let native = typikon::day::describe( + typikon::calendars::Date::parse_iso("2026-04-12").unwrap(), + jur, + ); + + let day = describe("2026-04-12", "moscow"); + unsafe { + assert_eq!(s(typikon_day_iso(day)), Some("2026-04-12")); + assert_eq!(s(typikon_day_title(day)), Some(native.title())); + assert_eq!(s(typikon_day_fast(day)), Some(native.fast.key())); + assert_eq!(s(typikon_day_emoji(day)), Some(native.emoji)); + assert_eq!(s(typikon_day_jurisdiction(day)), Some(native.jurisdiction)); + assert_eq!(s(typikon_day_church_date(day)), Some(&*native.church_date)); + assert_eq!(typikon_day_days_to_pascha(day), native.days_to_pascha); + assert_eq!(typikon_day_pascha(day), native.pascha.jdn()); + assert_eq!(typikon_day_julian_offset(day), native.julian_offset); + assert_eq!( + typikon_day_commemoration_count(day) as usize, + native.commemorations.len() + ); + typikon_day_free(day); + } +} + +#[test] +fn the_json_is_the_document_the_command_line_emits() { + let day = describe("2026-01-07", "moscow"); + let json = s(unsafe { typikon_day_json(day) }).expect("json"); + let parsed: serde_json::Value = serde_json::from_str(json).expect("valid json"); + + let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap(); + let native = typikon::day::describe( + typikon::calendars::Date::parse_iso("2026-01-07").unwrap(), + jur, + ); + let expected = serde_json::to_value(native.to_json()).unwrap(); + assert_eq!(parsed, expected); + + unsafe { typikon_day_free(day) }; +} + +#[test] +fn an_accessor_returns_the_same_pointer_every_time() { + // The point of building the strings once: a statusline that reads `title` + // on every redraw must not allocate on every redraw. An earlier version + // did, and kept every allocation alive until free. + let day = describe("2026-04-12", "moscow"); + unsafe { + let first = typikon_day_title(day); + for _ in 0..10_000 { + assert_eq!(typikon_day_title(day), first); + } + typikon_day_free(day); + } +} + +#[test] +fn a_null_day_answers_null_rather_than_crashing() { + let n: *const typikon_day = ptr::null(); + unsafe { + assert!(typikon_day_json(n).is_null()); + assert!(typikon_day_title(n).is_null()); + assert!(typikon_day_season(n).is_null()); + assert!(typikon_day_commemoration(n, 0).is_null()); + assert_eq!(typikon_day_commemoration_count(n), 0); + assert_eq!(typikon_day_is_obligatory(n), 0); + assert_eq!(typikon_day_days_to_pascha(n), 0); + assert_eq!(typikon_day_pascha(n), 0); + assert_eq!(typikon_day_julian_offset(n), 0); + typikon_day_free(ptr::null_mut()); // accepted and ignored + } +} + +#[test] +fn a_commemoration_past_the_end_is_null() { + let day = describe("2026-04-12", "moscow"); + unsafe { + let n = typikon_day_commemoration_count(day); + assert!(typikon_day_commemoration(day, n).is_null()); + assert!(typikon_day_commemoration(day, u32::MAX).is_null()); + if n > 0 { + assert!(!typikon_day_commemoration(day, 0).is_null()); + } + typikon_day_free(day); + } +} + +#[test] +fn a_jurisdiction_nobody_keeps_is_an_error() { + let key = CString::new("shire").unwrap(); + let day = unsafe { typikon_describe(typikon_today(), key.as_ptr()) }; + assert!(day.is_null()); + let msg = s(typikon_last_error()).expect("a message"); + assert!(msg.contains("shire"), "{msg}"); +} + +#[test] +fn a_null_jurisdiction_takes_the_remembered_default() { + let day = unsafe { typikon_describe(typikon_jdn_from_ymd(2026, 4, 12), ptr::null()) }; + assert!(!day.is_null()); + unsafe { + assert!(s(typikon_day_jurisdiction(day)).is_some()); + typikon_day_free(day); + } +} + +#[test] +fn the_discipline_flag_moves_what_is_shown_but_not_what_binds() { + // The Byzantine Catholic case the flag exists for: on a Wednesday of the + // Great Fast, Ruthenian law requires nothing, while the Typikon it inherits + // keeps xerophagy. + let jdn = typikon_jdn_from_ymd(2026, 3, 4); // Wednesday, Great Fast + let key = CString::new("ruthenian").unwrap(); + unsafe { + let law = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 0); + let typikon = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 1); + assert!(!law.is_null() && !typikon.is_null()); + + assert_ne!( + s(typikon_day_fast(law)), + s(typikon_day_fast(typikon)), + "the Typikon should keep more than the law requires" + ); + assert_eq!( + s(typikon_day_binds(law)), + s(typikon_day_binds(typikon)), + "what binds is the law either way" + ); + typikon_day_free(law); + typikon_day_free(typikon); + } +} + +#[test] +fn the_usage_can_be_overridden() { + // Fish in the Nativity Fast: Greek practice permits it on a weekday before + // 17 December, Slavic practice keeps it to Saturdays and Sundays. + let jdn = typikon_jdn_from_ymd(2025, 12, 9); // a Tuesday + let key = CString::new("old").unwrap(); + let (greek, slavic) = (CString::new("greek").unwrap(), CString::new("slavic").unwrap()); + unsafe { + let g = typikon_describe_full(jdn, key.as_ptr(), greek.as_ptr(), -1); + let sl = typikon_describe_full(jdn, key.as_ptr(), slavic.as_ptr(), -1); + assert_eq!(s(typikon_day_tradition(g)), Some("greek")); + assert_eq!(s(typikon_day_tradition(sl)), Some("slavic")); + typikon_day_free(g); + typikon_day_free(sl); + } +} + +#[test] +fn an_unknown_usage_is_an_error() { + let key = CString::new("moscow").unwrap(); + let bad = CString::new("frankish").unwrap(); + let day = unsafe { typikon_describe_full(typikon_today(), key.as_ptr(), bad.as_ptr(), -1) }; + assert!(day.is_null()); + assert!(s(typikon_last_error()).is_some()); +} + +// ────────────────────────── without a day ───────────────────────────────── + +#[test] +fn pascha_by_year_matches_the_published_dates() { + let old = CString::new("old").unwrap(); + let west = CString::new("western").unwrap(); + for (year, julian, gregorian) in [ + (2026, "2026-04-12", "2026-04-05"), + (2027, "2027-05-02", "2027-03-28"), + (2028, "2028-04-16", "2028-04-16"), + ] { + for (key, expected) in [(&old, julian), (&west, gregorian)] { + let jdn = unsafe { typikon_pascha(year, key.as_ptr()) }; + let (mut y, mut m, mut d) = (0i32, 0u32, 0u32); + assert_eq!(unsafe { typikon_ymd_from_jdn(jdn, &mut y, &mut m, &mut d) }, 0); + assert_eq!(format!("{y:04}-{m:02}-{d:02}"), expected); + } + } +} + +#[test] +fn pascha_refuses_a_jurisdiction_nobody_keeps() { + let key = CString::new("shire").unwrap(); + assert_eq!(unsafe { typikon_pascha(2026, key.as_ptr()) }, 0); + assert!(s(typikon_last_error()).is_some()); +} + +#[test] +fn every_jurisdiction_is_listed_and_every_listed_key_reckons() { + let n = typikon_jurisdiction_count(); + assert_eq!( + n as usize, + typikon::jurisdictions::JURISDICTIONS.len() + typikon::jurisdictions::CALENDARS.len() + ); + for i in 0..n { + let key = s(typikon_jurisdiction_key(i)).expect("a key"); + assert!(s(typikon_jurisdiction_name(i)).is_some_and(|x| !x.is_empty())); + assert!(s(typikon_jurisdiction_note(i)).is_some(), "note may be empty, not NULL"); + let day = describe("2026-04-12", key); + unsafe { typikon_day_free(day) }; + } + assert!(typikon_jurisdiction_key(n).is_null()); +} + +#[test] +fn the_version_is_the_crate_version() { + assert_eq!(s(typikon_version()), Some(env!("CARGO_PKG_VERSION"))); +} + +// ──────────────────────────────── the header ────────────────────────────── + +/// Every symbol the library exports is declared in `include/typikon.h`, and +/// every function the header declares is exported. +/// +/// A header is a promise made in a second file, and a second file drifts. This +/// is the cheapest check that it has not. +#[test] +fn the_header_describes_the_library() { + let src = std::fs::read_to_string("src/capi.rs").expect("src/capi.rs"); + let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h"); + + let mut exported: Vec<&str> = Vec::new(); + for line in src.lines() { + // `fn typikon_foo(` in a line that declares an extern function, plus + // the names the accessor macro generates, which are its first argument. + if let Some(rest) = line.trim().strip_prefix("pub extern \"C\" fn ") { + exported.push(rest.split('(').next().unwrap()); + } else if let Some(rest) = line.trim().strip_prefix("pub unsafe extern \"C\" fn ") { + exported.push(rest.split('(').next().unwrap()); + } else if let Some(rest) = line.trim().strip_prefix("accessor!(") { + exported.push(rest.split(',').next().unwrap()); + } + } + // The macro's own definition line reads as `fn $name(`; the names it + // generates are picked up from the invocations. + exported.retain(|n| !n.starts_with('$')); + assert!(exported.len() > 20, "found only {} symbols", exported.len()); + + for name in &exported { + assert!( + header.contains(&format!("{name}(")), + "{name} is exported but not declared in include/typikon.h" + ); + } + + for line in header.lines() { + let Some(start) = line.find("typikon_") else { continue }; + if !line.contains('(') || line.trim_start().starts_with('*') { + continue; + } + let name: String = line[start..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !line[start + name.len()..].starts_with('(') { + continue; // a mention in prose, not a declaration + } + assert!( + exported.contains(&name.as_str()), + "{name} is declared in include/typikon.h but not exported" + ); + } +} + +/// The header hard-codes the ABI version so a caller can compare what it +/// compiled against with what it loaded. +#[test] +fn the_headers_abi_version_matches_the_library() { + let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h"); + let line = header + .lines() + .find(|l| l.starts_with("#define TYPIKON_ABI_VERSION")) + .expect("the header defines it"); + let declared: u32 = line + .split_whitespace() + .next_back() + .unwrap() + .trim_end_matches('u') + .parse() + .expect("a number"); + assert_eq!(declared, typikon_abi_version()); +} + +/// The LuaJIT binding declares the library it loads, and a declaration that +/// has drifted from the header is a segfault rather than a compile error. +#[test] +fn the_lua_binding_declares_the_whole_header() { + let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h"); + let lua = std::fs::read_to_string("examples/lua/typikon.lua").expect("the lua binding"); + let cdef = { + let start = lua.find("ffi.cdef").expect("a cdef block"); + let body = &lua[start..]; + let end = body.find("]]").expect("the block ends"); + &body[..end] + }; + + for line in header.lines() { + let Some(start) = line.find("typikon_") else { continue }; + let name: String = line[start..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !line[start + name.len()..].starts_with('(') { + continue; + } + assert!( + cdef.contains(&format!("{name}(")), + "{name} is in the header but not in the lua cdef" + ); + } +}