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
+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