a C ABI, so this can be embedded rather than spawned

Every desktop shell that reads typikon today runs the binary and parses
its JSON. That costs about 1.5ms and is the right answer for a calendar
that changes once a day, but it is the wrong shape for anything with an
FFI and no business spawning a process on every redraw.

Dates cross as Julian Day Numbers, so no struct layout is part of the
contract. A day is opaque and owns its strings, built once when it is
reckoned: an accessor returns the same borrowed pointer every time, so a
statusline polling one in a loop allocates nothing. The accessors carry
every field of the JSON, so nothing on the other side needs a parser.

The Python bindings and this were resolving a jurisdiction by two copies
of the same logic, and the copy here leaked one per call. Both now share
jurisdictions::resolve_full, which interns the combinations instead.

The header is a promise made in a second file, and a second file drifts:
a test reads both and fails if either has a symbol the other does not,
and does the same for the LuaJIT cdef, where a stale declaration is a
segfault rather than a compile error.
This commit is contained in:
funkpower
2026-09-18 14:46:25 -04:00
parent 452d7fc0a1
commit d90d4c171f
14 changed files with 1523 additions and 98 deletions
+17 -1
View File
@@ -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
+17
View File
@@ -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
+3
View File
@@ -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 = []
+51
View File
@@ -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.h>
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
+86
View File
@@ -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 <stdio.h>
#include <string.h>
#include <typikon.h>
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;
}
+40
View File
@@ -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))))
+168
View File
@@ -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
+152
View File
@@ -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 <stdint.h>
#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 */
+487
View File
@@ -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<Option<CString>> = const { RefCell::new(None) };
}
fn set_error(msg: impl Into<Vec<u8>>) {
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<CString>,
cycle: Option<CString>,
feast: Option<CString>,
rank: Option<CString>,
sign: Option<CString>,
obligation: Option<CString>,
binds: Option<CString>,
commemorations: Vec<CString>,
}
#[allow(clippy::too_many_arguments)]
fn build(
jdn: i32,
jurisdiction: Option<&str>,
tradition: Option<&str>,
discipline: c_int,
) -> Option<Box<typikon_day>> {
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<Vec<(CString, CString, CString)>> = 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())
}
+53
View File
@@ -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<bool>,
) -> 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<Mutex<Interned>> = 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 }))
}))
}
+44
View File
@@ -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::<i64>()).unwrap_or(0);
let m = (s[3..5].parse::<i64>()).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
}
+1 -39
View File
@@ -419,7 +419,7 @@ fn run() -> Result<i32, String> {
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<i32, String> {
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::<i64>()).unwrap_or(0);
let m = (s[3..5].parse::<i64>()).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"])
+4 -58
View File
@@ -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<Bound<'py, PyDate>> {
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<bool>,
) -> 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<Mutex<Interned>> = 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.
+400
View File
@@ -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"
);
}
}