diff options
| -rw-r--r-- | bin/main.ml | 628 | ||||
| -rw-r--r-- | lib/render/emit_ics.ml | 13 | ||||
| -rw-r--r-- | man/colitur.1 | 322 | ||||
| -rw-r--r-- | test/cli.t | 252 |
4 files changed, 1091 insertions, 124 deletions
diff --git a/bin/main.ml b/bin/main.ml index b2eef28..0a09a63 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -131,41 +131,20 @@ let data_dir () = end | _ -> if has_data installed then installed else build_tree -(* TEMPORARY BRIDGE (naming-and-config Task 5): [View.of_days] now takes - [~lang], but real CLI flag/config resolution (--lang, --raw, - Colitur_naming.Config) is Task 6's own scope, not this one's. Rather than - leave `emit`/`table`/`publish` showing bare slugs as their "resolved - name" -- which is precisely the defect this whole branch exists to fix -- - this loads the shipped Latin table by default, using EXACTLY the probe - order [data_dir] above already uses (installed prefix, then the build - tree), so an installed binary finds its language file the same way it - finds its calendar data. Task 6 replaces this wholesale with - `--lang`/`--raw`/config resolution and per-command defaults; nothing - here is meant to survive that task unchanged. A missing or malformed - language file degrades to [Lang.raw] (name = slug) rather than crashing - the CLI -- Task 6 is what makes that case a proper, reported error. *) +(* [lang_dir] mirrors [data_dir]'s own probe order exactly -- installed + prefix first, then the build tree -- because a language file must resolve + the same way calendar data does, or an installed binary could find one + and not the other (exactly the defect a prior fix round found: lang/ had + no install rule at all, and the installed binary silently fell back to + raw slugs with no error). Accepted only if la.ini is actually readable + there, matching [data_dir]'s own "a candidate counts only if the data is + really there" discipline. *) let lang_dir () = let prefix = Filename.dirname (Filename.dirname Sys.executable_name) in let installed = List.fold_left Filename.concat prefix [ "share"; "colitur"; "lang" ] in if Sys.file_exists (Filename.concat installed "la.ini") then installed else Filename.concat prefix "lang" -let default_lang = - lazy - (let path = Filename.concat (lang_dir ()) "la.ini" in - match open_in_bin path with - | exception Sys_error _ -> Colitur_naming.Lang.raw - | ic -> ( - match - Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () -> - really_input_string ic (in_channel_length ic)) - with - | exception Sys_error _ -> Colitur_naming.Lang.raw - | text -> ( - match Colitur_naming.Lang.of_string text with - | Ok t -> t - | Error _ -> Colitur_naming.Lang.raw))) - (* Loads the universal sanctoral layer and applies the one hand-authored overlay over it (data/ef/adjustments.sexp -- see that file's own header): [Overlay.apply]'s diagnostics are never silently dropped (Overlay.mli), @@ -244,7 +223,26 @@ let load_ef_commons () = | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e) | Ok commons -> Ok commons -let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) +(* [~lang] resolves the observed slug to a display NAME, appended as a new + LAST field rather than substituted into the slug's own position: a name + contains spaces (Latin and English both), and inserting it where the slug + used to sit would break every fixed-position field after it (rank, + colour, the commemoration tail) for anyone parsing this line by column -- + the same reasoning that keeps `readings` a separate command rather than + extra columns on `day`. [slug] itself is therefore untouched by [lang] in + this row, exactly as [Colitur_render.View]'s own [slug] field is. + + The trailing field is present only when the resolved name actually + DIFFERS from the slug -- not gated on [--raw] as a special case, but as a + direct consequence of [Lang.raw] being the identity table (lang.mli): + under [--raw], [name = slug] always, so the field is always absent and + this row is BYTE-IDENTICAL to what it printed before this feature + existed, with no `if raw then ...` branch anywhere in this function. The + same holds for any language file that has genuinely no entry for a given + slug -- a miss also returns the slug (lang.mli), so an untranslated day + quietly gets no trailing field rather than a field that redundantly + repeats the slug it already printed. *) +let day_line ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let t = d.Colitur_kernel.Liturgical_day.temporal in let cel = d.Colitur_kernel.Liturgical_day.observed in @@ -257,14 +255,16 @@ let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kerne let commemorations = String.concat "" (List.map commemoration_suffix d.Colitur_kernel.Liturgical_day.commemorations) in - Printf.printf "%s %s %s %s %s %s %s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + let name = Colitur_naming.Lang.celebration lang slug_s in + let name_suffix = if name = slug_s then "" else " " ^ name in + Printf.printf "%s %s %s %s %s %s %s%s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) (D.weekday_to_string t.Colitur_kernel.Temporal.weekday) (Rite_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season) - week - (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug) + week slug_s (Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank) (Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour) - commemorations + commemorations name_suffix (* The reading citations for a day, as its own row shape rather than extra columns on [day_line]'s. @@ -293,7 +293,14 @@ let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kerne "citations-unresolved" checks assert exactly one First and one Gospel on every day of every year 1583..9999 -- but the CLI must not assume a guarantee the kernel makes about DATA rather than about types. *) -let readings_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) +(* [~lang]: same append-only rule as [day_line] above, using this row's own + " | " field separator (chosen there precisely because a citation may + contain spaces or commas) rather than a plain space -- a resolved name + can equally contain spaces, and " | " is what already keeps this row's + fields unambiguous. Present only when the resolved name differs from the + slug, for the identical reason [day_line] gives: under [--raw] this row + is therefore byte-identical to what it printed before. *) +let readings_line ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let cel = d.Colitur_kernel.Liturgical_day.observed in let part_ref p = @@ -305,11 +312,15 @@ let readings_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_ | Some c -> c.Colitur_kernel.Citation.reference | None -> "-" in - Printf.printf "%s %s | %s | %s\n" + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + let name = Colitur_naming.Lang.celebration lang slug_s in + let name_suffix = if name = slug_s then "" else " | " ^ name in + Printf.printf "%s %s | %s | %s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) - (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug) + slug_s (part_ref Colitur_kernel.Citation.First) (part_ref Colitur_kernel.Citation.Gospel) + name_suffix (* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single @@ -402,8 +413,8 @@ let resolved_year_days ~overlays y = let resolved_year_report ~line ~overlays y = List.iter line (resolved_year_days ~overlays y) -let day_report ~overlays y = resolved_year_report ~line:day_line ~overlays y -let readings_report ~overlays y = resolved_year_report ~line:readings_line ~overlays y +let day_report ~lang ~overlays y = resolved_year_report ~line:(day_line ~lang) ~overlays y +let readings_report ~lang ~overlays y = resolved_year_report ~line:(readings_line ~lang) ~overlays y (* Task 8: `colitur emit` -- the five template-family emitters built in Tasks 5-7, wired to a year RANGE rather than a single year, because a @@ -454,7 +465,7 @@ let check_dtstamp = function s; exit 2 -let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = +let emit_report ~lang ~format ~overlays ~dtstamp ~from_y ~to_y = check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; @@ -463,7 +474,7 @@ let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = for y = from_y to to_y do let days = resolved_year_days ~overlays y in let v = - Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days + Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in match format with | "csv" -> @@ -538,6 +549,98 @@ let read_file path = Ok s) with exn -> Error (Printf.sprintf "cannot read template %s: %s" path (Printexc.to_string exn))) +(* The config file supplies DEFAULTS for --lang, --overlay, --template and + --format when the corresponding flag is absent -- Colitur_naming.Config + owns precedence (flag > config > default). Location follows the XDG base + directory convention: $XDG_CONFIG_HOME/colitur/config.ini, or + ~/.config/colitur/config.ini when that variable is unset or blank. An + unresolvable HOME (neither variable set) means no config path at all -- + [load_config] below treats that exactly like a missing file, not an + error, since colitur without any config must keep working. *) +let config_path () = + match Sys.getenv_opt "XDG_CONFIG_HOME" with + | Some d when String.trim d <> "" -> Filename.concat d "colitur/config.ini" + | _ -> ( + match Sys.getenv_opt "HOME" with + | Some h -> Filename.concat h ".config/colitur/config.ini" + | None -> "") + +(* A config file is OPTIONAL: none at [config_path ()] behaves exactly as + colitur always has (Colitur_naming.Config.empty). A file that EXISTS but + fails to read or parse is fatal -- a config the user wrote and colitur + cannot honour is not something to silently carry on past. An unknown key + or section is reported but never fatal (Config.mli's own contract): a + config written for a newer colitur must still work on an older one, but a + silently-ignored typo is how a setting the user believes is active quietly + does nothing. Two separate warnings, not one, so a misspelled SECTION + (e.g. [deafults]) reads differently from a misspelled KEY inside a + recognised one -- Config.mli documents exactly this distinction. *) +let load_config () = + let p = config_path () in + if p = "" || not (Sys.file_exists p) then Colitur_naming.Config.empty + else + match read_file p with + | Error _ -> + Printf.eprintf "colitur: cannot read config file %s\n" p; + exit 2 + | Ok text -> ( + match Colitur_naming.Config.of_string text with + | Error msg -> + Printf.eprintf "colitur: %s: %s\n" p msg; + exit 2 + | Ok c -> + List.iter + (fun k -> Printf.eprintf "colitur: %s: unknown setting %S (ignored)\n" p k) + (Colitur_naming.Config.unknown_keys c); + List.iter + (fun s -> Printf.eprintf "colitur: %s: unknown section [%s] (ignored)\n" p s) + (Colitur_naming.Config.unknown_sections c); + c) + +(* An unknown language is an ERROR naming what is available, never a silent + fallback to Latin: a booklet quietly printed in the wrong language is + worse than one that refuses to print. [raw] takes priority + unconditionally and short-circuits everything else -- [--raw] IS + [Lang.raw], the identity table, handed to the caller directly, rather + than a second "is this raw" case threaded through every call site + downstream (View.of_days, day_line, readings_line all just take a + [Lang.t] and do not know or care whether it came from [--raw] or a real + file). *) +let load_lang ~raw ~flag ~config = + if raw then Colitur_naming.Lang.raw + else + let code, _src = Colitur_naming.Config.resolve ~flag ~config ~default:"la" in + let path = + if String.contains code '/' || Filename.check_suffix code ".ini" then code + else Filename.concat (lang_dir ()) (code ^ ".ini") + in + match read_file path with + | Error _ -> + Printf.eprintf "colitur: no language %S (looked in %s); try: colitur lang --list\n" code + (lang_dir ()); + exit 2 + | Ok text -> ( + match Colitur_naming.Lang.of_string text with + | Error msg -> + Printf.eprintf "colitur: %s: %s\n" path msg; + exit 2 + | Ok t -> ( + (* Chain to the declared fallback, so a partial translation shows + its fallback language rather than bare slugs. A fallback that + itself fails to load or parse does not take [t] down with it + -- [t] is already a good table; losing only the fallback field + is better than losing the whole language over a defect in a + file [t] merely NAMES. *) + match Colitur_naming.Lang.fallback_code t with + | None -> t + | Some fb -> ( + match read_file (Filename.concat (lang_dir ()) (fb ^ ".ini")) with + | Error _ -> t + | Ok ftext -> ( + match Colitur_naming.Lang.of_string ftext with + | Error _ -> t + | Ok base -> Colitur_naming.Lang.with_fallback t base)))) + let extension path = match String.rindex_opt path '.' with | Some i -> String.sub path i (String.length path - i) @@ -548,7 +651,7 @@ let extension path = output (unescaped LaTeX/HTML metacharacters) that looks fine until it does not -- the same "never silently substitute" discipline [data_dir]'s own [COLITUR_DATA_DIR] handling documents above. *) -let table_report ~template ~flavour_opt ~overlays y = +let table_report ~lang ~template ~flavour_opt ~overlays y = let flavour = match flavour_opt with | Some name -> ( @@ -573,7 +676,7 @@ let table_report ~template ~flavour_opt ~overlays y = exit 2 | Ok src -> ( let days = resolved_year_days ~overlays y in - let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in + let v = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in match Colitur_render.Template.render_string ~flavour src v with | Error e -> (* The template is user input; a parse failure is reported with the @@ -760,7 +863,7 @@ let index_html ~from_y ~to_y = </body></html>\n"; Buffer.contents b -let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = +let publish_report ~lang ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; @@ -792,7 +895,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = in for y = from_y to to_y do let days = resolved_year_days ~overlays y in - let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in + let v = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in let ys = string_of_int y in emit ("ef/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v); emit ("ef/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v); @@ -805,7 +908,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = (fun d -> let iso = D.to_iso8601 d.Colitur_kernel.Liturgical_day.date in let mm = String.sub iso 5 2 and dd = String.sub iso 8 2 in - let one = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in + let one = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one)) days done; @@ -866,20 +969,26 @@ usage: colitur temporal <year> the temporal cycle, one line per day colitur day <year> the resolved day identity, one line per day colitur readings <year> the Mass reading citations, one line per day - colitur day|readings <year> --overlay FILE [--overlay FILE ...] + colitur day|readings <year> [--overlay FILE ...] [--lang CODE|FILE] [--raw] colitur emit --format csv|json|sexp|xml|ics --from Y --to Y - [--overlay FILE ...] [--dtstamp S] + [--overlay FILE ...] [--dtstamp S] [--lang CODE|FILE] [--raw] render a resolved year range through one of five emitters colitur table --year Y --template FILE [--flavour X] [--overlay FILE ...] + [--lang CODE|FILE] [--raw] colitur render --template FILE --year Y [--flavour X] [--overlay FILE ...] + [--lang CODE|FILE] [--raw] compute year Y and render it through FILE, a logic-less Mustache-family template; table and render are the same operation, two names (see "rendering" below) colitur publish --from Y --to Y --out DIR [--overlay FILE ...] [--prune] - [--dtstamp S] + [--dtstamp S] [--lang CODE|FILE] [--raw] write the static tree: per-year csv/json/xml/ics, one JSON file per day, the schema and a generated index (see "publish" below) + colitur lang --list which language files this build can find + colitur lang --dump CODE a language's full key set, in INI form, on stdout + colitur lang --check FILE what a language file is missing, and any typo'd key + colitur config --show every setting, its value and where it came from colitur new-overlay print a starter overlay file to stdout colitur convert FILE.ini flat INI overlay -> S-expression, on stdout colitur check FILE ... load an overlay, say what it does, exit 2 if not @@ -890,14 +999,21 @@ usage: 31 December of that year, not a liturgical year. output formats: - day date weekday season week slug rank colour [+commemoration ...] + day date weekday season week slug rank colour [+commemoration ...] [name] 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white - readings date slug | Epistle | Gospel + readings date slug | Epistle | Gospel [| name] 2026-12-25 ef-nativity | Heb 1:1-12 | John 1:1-14 A citation contains spaces, so readings uses " | " between its fields while day stays space-separated; that is why they are separate commands rather - than extra columns. + than extra columns. The same reasoning is why the resolved display NAME, + present by default (see "naming" below), is appended as the LAST field + rather than substituted for the slug shown above: a name may itself + contain spaces, and inserting it earlier in the row would break every + fixed-position field that follows it. It is present only when it differs + from the slug already shown -- under --raw, or any language with no entry + for that particular day, the trailing field is simply absent, which is + what makes --raw byte-identical to this program's pre-naming output. emit one schema (season, week, slug, rank, colour, subject, names, citations, commemorations), rendered five ways: csv (RFC 4180, @@ -942,6 +1058,44 @@ overlays: (Nth_weekday (month M) (nth N) (weekday W)) with N negative to count from the end of the month. +naming: + --lang CODE|FILE applies to `day`, `readings`, `emit`, `table`, `render` + and `publish`. A CODE (e.g. `la`, `en`) is looked up as + <lang-dir>/CODE.ini; a value containing '/' or ending ".ini" is + read as a literal path instead. Default `la`, overridable by a + config file (see below). An unknown language is a hard ERROR + naming what is available (try `colitur lang --list`), never a + silent fallback to Latin -- a booklet quietly printed in the + wrong language is worse than one that refuses to print. A + language file may declare `fallback = CODE` in its [meta] + section, so a partial translation shows its fallback language + for the keys it does not itself carry, rather than bare slugs. + + --raw restores every command's pre-naming output: `name` (and every + other localised field `emit`'s schema carries) equals the bare + machine slug, exactly as if no language had ever been resolved. + This is not a special case threaded through the naming code -- + it is `colitur lang`'s own identity table, under which every + lookup echoes its key back unchanged. + + colitur lang --list which language files this build can find, and + each one's own declared fallback, if any. + colitur lang --dump CODE the named language's full key set, in INI + form, on stdout -- a starting point for a new + translation, or a way to diff two versions of + one. + colitur lang --check FILE what a language file is MISSING (a real slug + with no entry) and, separately, any entry + naming a slug that does not exist at all (a + typo, silently dead otherwise) -- exits 1 if + anything is unknown, so it fits a Makefile or + a pre-commit hook. + colitur config --show every effective setting -- lang, overlay, + template, format -- its resolved value, and + where it came from: `flag`, `config` or + `default`. See colitur-config(5) for the + config file's location and full precedence. + rendering: --template FILE (required on `table`/`render`) is a logic-less Mustache- family template: {{placeholder}}, {{#section}}...{{/section}}, @@ -953,10 +1107,11 @@ rendering: slug, rank, colour, subject, names, citations, commemorations), reshaped into a booklet (`days`) and a month grid (`weeks`, with padding cells for the leading/trailing blanks); see - colitur-templates(5) for the full field list, the syntax and the - scope-shadowing hazard (an inner key silently loses to an outer - key of the same name -- a bare {{name.la}} inside a day resolves - to the enclosing MONTH's name, not the day's own). + colitur-templates(5) for the full field list, the syntax, and + the one remaining scope hazard (`num` -- both a month and a week + carry it, and only the innermost one is ever meant). `name` is a + plain resolved string, not a lang-keyed object, so it carries no + equivalent hazard of its own. --flavour X selects how interpolated VALUES are escaped (never the template's own literal markup, which is the author's). One of: @@ -1033,10 +1188,11 @@ exit status: Reading references only (e.g. "Jn 3:16"); never scripture text. See colitur(1) for the full description and the sources it computes against, -colitur-overlay(5) for the overlay file format in full, and +colitur-overlay(5) for the overlay file format in full, colitur-templates(5) for the template format in full -- the syntax, the -scope-shadowing hazard, the six flavours' escaping, and the full view-model -field reference.|} +remaining scope hazard, the six flavours' escaping, and the full view-model +field reference -- and colitur-config(5) for the config file's location and +precedence in full.|} let print_help () = print_endline help_text; @@ -1047,7 +1203,8 @@ let usage () = "colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur \ readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template \ FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | \ - colitur check FILE | colitur new-overlay (try: colitur --help)"; + colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | \ + colitur new-overlay (try: colitur --help)"; exit 2 let with_year ys f = @@ -1077,9 +1234,14 @@ let with_year ys f = `table`/`render` take one year and one template file, never a range or a repeatable list. *) (* [out] (Task 12, `publish`) is single-valued like [format]/[year]/etc. - [prune] is the one plain boolean flag in this whole record -- every other - field here takes a value, but [--prune] does not, so it cannot reuse the - `"--flag" :: v :: rest` shape the value-taking flags share below. *) + [prune] is a plain boolean flag -- every other field up to here takes a + value, but [--prune] does not, so it cannot reuse the `"--flag" :: v :: + rest` shape the value-taking flags share below. *) +(* [lang]/[raw] (Task 6): [lang] is single-valued like [format]/[template]/ + etc (a language CODE or a file path); [raw] is a second plain boolean, + the same shape as [prune]. [dump]/[check]/[list]/[show] (Task 7, + `colitur lang`/`colitur config`) follow the identical two shapes -- + [dump]/[check] each take one value, [list]/[show] are bare. *) type parsed_args = { overlays : string list; format : string option; @@ -1091,6 +1253,12 @@ type parsed_args = { flavour : string option; out : string option; prune : bool; + lang : string option; + raw : bool; + dump : string option; + check : string option; + list : bool; + show : bool; positional : string list; } @@ -1116,6 +1284,15 @@ let parse_args argv = | "--out" :: v :: rest -> go { acc with out = Some v } rest | [ "--out" ] -> Error "--out needs a directory path" | "--prune" :: rest -> go { acc with prune = true } rest + | "--lang" :: v :: rest -> go { acc with lang = Some v } rest + | [ "--lang" ] -> Error "--lang needs a language code or file path" + | "--raw" :: rest -> go { acc with raw = true } rest + | "--dump" :: v :: rest -> go { acc with dump = Some v } rest + | [ "--dump" ] -> Error "--dump needs a language code" + | "--check" :: v :: rest -> go { acc with check = Some v } rest + | [ "--check" ] -> Error "--check needs a file path" + | "--list" :: rest -> go { acc with list = true } rest + | "--show" :: rest -> go { acc with show = true } rest (* The recognised bare flags pass through as positional words for the dispatch below to match; anything else beginning with '-' is rejected rather than silently treated as a command or a year. *) @@ -1128,7 +1305,8 @@ let parse_args argv = in go { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; year = None; - template = None; flavour = None; out = None; prune = false; positional = [] } + template = None; flavour = None; out = None; prune = false; lang = None; raw = false; + dump = None; check = None; list = false; show = false; positional = [] } argv (* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning @@ -1186,6 +1364,29 @@ let reject_publish_flags_for cmd ~out ~prune = exit 2 end +(* Sibling to the four rejectors above: `--lang`/`--raw` (Task 6) have + meaning only on the commands that resolve display names -- + `day`/`readings`/`emit`/`table`/`render`/`publish` -- and not on + `easter`/`temporal` (read no sanctoral data, exactly like `--overlay`), + `check`/`convert`/`new-overlay` (operate on overlay files, not a + rendered calendar), or `lang`/`config` themselves (which take their OWN + flags, `--dump`/`--check`/`--list`/`--show`, disjoint from these). *) +let reject_lang_for cmd ~lang ~raw = + if lang <> None || raw then begin + Printf.eprintf "colitur: --lang/--raw have no effect on `%s`; refusing rather than ignoring them\n" cmd; + exit 2 + end + +(* Sibling again: `--dump`/`--check`/`--list`/`--show` (Task 7) belong only + to `colitur lang` and `colitur config` respectively. *) +let reject_lang_subcommand_flags_for cmd ~dump ~check ~list ~show = + if dump <> None || check <> None || list || show then begin + Printf.eprintf + "colitur: --dump/--check/--list/--show have no effect on `%s`; refusing rather than ignoring them\n" + cmd; + exit 2 + end + (* `colitur check FILE...` -- load a user overlay, apply it to the real shipped calendar, and say what it did, without printing a year of output. @@ -1290,6 +1491,184 @@ let convert_report path = print_string sexp; exit 0) +(* `colitur lang --list` -- what language files this build can find, from + [lang_dir ()], the same probe [load_lang] itself uses. Each is opened and + parsed (not merely listed by filename) so a malformed file is flagged + here rather than only failing later when someone actually tries to use + it. *) +let lang_list () = + let dir = lang_dir () in + match Sys.readdir dir with + | exception Sys_error _ -> + Printf.eprintf "colitur: no language directory at %s\n" dir; + exit 2 + | files -> + Array.sort compare files; + Array.iter + (fun f -> + if Filename.check_suffix f ".ini" then begin + let code = Filename.remove_extension f in + match read_file (Filename.concat dir f) with + | Ok t -> ( + match Colitur_naming.Lang.of_string t with + | Ok l -> + Printf.printf "%-6s %s\n" code + (match Colitur_naming.Lang.fallback_code l with + | Some fb -> "(falls back to " ^ fb ^ ")" + | None -> "") + | Error e -> Printf.printf "%-6s (unreadable: %s)\n" code e) + | Error e -> Printf.printf "%-6s (unreadable: %s)\n" code e + end) + files + +(* Every slug the engine can actually emit -- the observed office AND every + commemoration/transfer, exactly the four-field walk + test/test_lang_coverage.ml's own [slugs_of_day] performs -- over the same + 2020-2045 window that test measures against. This is [colitur lang + --check]'s own reference set: a language file is judged against what the + engine can really produce, not against an arbitrarily chosen sample. *) +let all_known_slugs () = + match load_ef_data () with + | Error msg -> + Printf.eprintf "colitur: %s\n" msg; + exit 2 + | Ok (layer, lectionary, commons) -> + let context = Rite_ef.context ~lectionary ~commons in + let slug (c : _ Colitur_kernel.Celebration.t) = + Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug + in + let slugs_of_day + (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = + slug d.Colitur_kernel.Liturgical_day.observed + :: List.map (fun (c, _priv) -> slug c) d.Colitur_kernel.Liturgical_day.commemorations + @ (match d.Colitur_kernel.Liturgical_day.transferred_in with + | None -> [] + | Some c -> [ slug c ]) + @ List.map (fun (c, _date) -> slug c) d.Colitur_kernel.Liturgical_day.transferred_out + in + let seen = Hashtbl.create 1024 in + for y = 2020 to 2045 do + Array.iter + (fun d -> List.iter (fun s -> Hashtbl.replace seen s ()) (slugs_of_day d)) + (Colitur_kernel.Calendar.year context layer y) + done; + Hashtbl.fold (fun k () acc -> k :: acc) seen [] |> List.sort compare + +(* `colitur lang --dump CODE` -- the named language's full key set, in INI + form, on stdout for redirection. [Lang.keys] already returns every + (section-qualified key, value) pair SORTED as one flat list (lang.mli): + because every key sharing a section also shares that section's own + string prefix, and no two section names are a prefix of one another, the + sort keeps every section's own keys contiguous -- so a single pass + emitting a fresh `[section]` header each time the prefix changes + reconstructs proper INI blocks, in a FIXED (alphabetical) order, with no + second grouping step. Two dumps of the same table are therefore + byte-identical, which is what makes `--dump` diffable across two + versions of a translation. *) +let lang_dump code = + let path = Filename.concat (lang_dir ()) (code ^ ".ini") in + match read_file path with + | Error _ -> + Printf.eprintf "colitur: no language %S (looked in %s); try: colitur lang --list\n" code + (lang_dir ()); + exit 2 + | Ok text -> ( + match Colitur_naming.Lang.of_string text with + | Error msg -> + Printf.eprintf "colitur: %s: %s\n" path msg; + exit 2 + | Ok t -> + Printf.printf "[meta]\nlang = %s\n" (Colitur_naming.Lang.code t); + (match Colitur_naming.Lang.fallback_code t with + | Some fb -> Printf.printf "fallback = %s\n" fb + | None -> ()); + let cur = ref "" in + List.iter + (fun (qk, v) -> + match String.index_opt qk '.' with + | None -> () + | Some i -> + let section = String.sub qk 0 i in + let key = String.sub qk (i + 1) (String.length qk - i - 1) in + if section <> !cur then begin + print_newline (); + Printf.printf "[%s]\n" section; + cur := section + end; + Printf.printf "%s = %s\n" key v) + (Colitur_naming.Lang.keys t)) + +(* `colitur lang --check FILE` -- the difference between "you may write a + language file" and "you can": without this, a translator has to + reverse-engineer the key set from source. Reports what is MISSING + (a real slug the file has no entry for) AND what is UNKNOWN (a + [celebration] key matching no real slug at all -- a typo, or a slug from + a version of colitur this file was not written against) -- a key + matching nothing is silently dead, and its author would otherwise never + learn why their translation does not appear. Exits 2 only on UNKNOWN: a + partial file (some slugs missing) is exactly the shippable, in-progress + state [Lang.with_fallback] exists for, but a typo naming nothing is + always worth flagging as a failure, the same "never silently dead" + standard [Config.unknown_keys] holds a config file to. *) +let lang_check path = + match read_file path with + | Error msg -> + Printf.eprintf "colitur: %s\n" msg; + exit 2 + | Ok text -> ( + match Colitur_naming.Lang.of_string text with + | Error msg -> + Printf.eprintf "colitur: %s: %s\n" path msg; + exit 2 + | Ok t -> + let known = all_known_slugs () in + let have = + List.filter_map + (fun (k, _) -> + match String.index_opt k '.' with + | Some i when String.sub k 0 i = "celebration" -> + Some (String.sub k (i + 1) (String.length k - i - 1)) + | _ -> None) + (Colitur_naming.Lang.keys t) + in + let missing = List.filter (fun s -> not (List.mem s have)) known in + let unknown = List.filter (fun s -> not (List.mem s known)) have in + List.iter (fun s -> Printf.printf "missing: %s\n" s) (List.sort compare missing); + List.iter (fun s -> Printf.printf "unknown slug: %s\n" s) (List.sort compare unknown); + Printf.printf "%s: %d of %d celebrations named, %d missing, %d unknown\n" path + (List.length known - List.length missing) + (List.length known) (List.length missing) (List.length unknown); + if unknown <> [] then exit 1) + +(* `colitur config --show` -- each effective setting, its resolved value, + and where it came from (`flag`/`config`/`default`), via + [Config.resolve]. There is deliberately no separate "provenance" + function: [resolve] already returns the source alongside the value + (config.mli), and a second entry point recomputing it independently + would let the two disagree. [lang]/[template]/[format] are scalars, each + resolved the same way `colitur day`/`table`/`emit` would resolve them + given these SAME command-line flags (so `config --show --lang fr` shows + exactly what a real `--lang fr` run would use); [overlay] is a list, so + it has no single "value" to resolve -- shown as one line per effective + entry instead, with its own source. *) +let config_show ~lang_flag ~template_flag ~format_flag ~overlays_flag config = + let cpath = config_path () in + Printf.printf "config file: %s (%s)\n" cpath + (if cpath <> "" && Sys.file_exists cpath then "exists" else "not found"); + let scalar name flag cfgval default = + let v, src = Colitur_naming.Config.resolve ~flag ~config:cfgval ~default in + Printf.printf "%-10s %-24s (%s)\n" name v src + in + scalar "lang" lang_flag (Colitur_naming.Config.lang config) "la"; + scalar "template" template_flag (Colitur_naming.Config.template config) "(none)"; + scalar "format" format_flag (Colitur_naming.Config.format config) "(none)"; + (match overlays_flag with + | _ :: _ as l -> List.iter (fun o -> Printf.printf "%-10s %-24s (%s)\n" "overlay" o "flag") l + | [] -> ( + match Colitur_naming.Config.overlays config with + | [] -> Printf.printf "%-10s %-24s (%s)\n" "overlay" "(none)" "default" + | l -> List.iter (fun o -> Printf.printf "%-10s %-24s (%s)\n" "overlay" o "config") l)) + (* `colitur new-overlay` -- a starter file on stdout, for redirection. Deliberately printed rather than written: the user picks the path, and a command that creates files where it likes is a worse citizen. Every value is @@ -1350,22 +1729,51 @@ let () = | Error msg -> Printf.eprintf "colitur: %s\n" msg; usage () - | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; positional } -> ( + | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang; + raw; dump; check; list; show; positional } -> ( let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in let reject_table = reject_table_flags_for ~year ~template ~flavour in let reject_publish = reject_publish_flags_for ~out ~prune in + let reject_lang = reject_lang_for ~lang ~raw in + let reject_lang_sub = reject_lang_subcommand_flags_for ~dump ~check ~list ~show in + (* Loaded once, unconditionally: a config file the user wrote and + colitur cannot honour (missing HOME aside, [load_config] treats + that as "no config" rather than an error) is worth surfacing on + EVERY invocation, `--help` included, not only the commands that + happen to consult it -- the same "never silently ignored" + discipline [COLITUR_DATA_DIR] already gets. *) + let config = load_config () in + (* Resolved only inside the branches that actually consume it, as a + thunk rather than eagerly here: [load_lang] can [exit 2] (an + unknown language, a malformed file), and a command that never asked + for naming at all (`easter`, `check`, ...) must not be able to fail + on account of a language it never uses -- those commands reject + [--lang]/[--raw] outright instead, via [reject_lang] above. *) + let resolved_lang () = load_lang ~raw ~flag:lang ~config:(Colitur_naming.Config.lang config) in + (* Config supplies a DEFAULT overlay list only when NO --overlay was + given at all -- not merged with a partial CLI list -- so the + precedence stays exactly flag > config > default, the same + direction every other setting resolves in, rather than a list + merge whose ordering nothing documents. *) + let effective_overlays = + if overlays = [] then Colitur_naming.Config.overlays config else overlays + in match positional with | [ ("-h" | "--help" | "help") ] -> reject_overlays_for "--help" overlays; reject_emit "--help"; reject_table "--help"; reject_publish "--help"; + reject_lang "--help"; + reject_lang_sub "--help"; print_help () | [ ("-V" | "--version" | "version") ] -> reject_overlays_for "--version" overlays; reject_emit "--version"; reject_table "--version"; reject_publish "--version"; + reject_lang "--version"; + reject_lang_sub "--version"; print_endline version; exit 0 | [ "easter"; ys ] -> @@ -1373,46 +1781,110 @@ let () = reject_emit "easter"; reject_table "easter"; reject_publish "easter"; + reject_lang "easter"; + reject_lang_sub "easter"; with_year ys easter_report | [ "temporal"; ys ] -> reject_overlays_for "temporal" overlays; reject_emit "temporal"; reject_table "temporal"; reject_publish "temporal"; + reject_lang "temporal"; + reject_lang_sub "temporal"; with_year ys temporal_report | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; reject_emit "check"; reject_table "check"; reject_publish "check"; + reject_lang "check"; + reject_lang_sub "check"; check_report files | [ "convert"; path ] -> reject_overlays_for "convert" overlays; reject_emit "convert"; reject_table "convert"; reject_publish "convert"; + reject_lang "convert"; + reject_lang_sub "convert"; convert_report path | [ "new-overlay" ] -> reject_overlays_for "new-overlay" overlays; reject_emit "new-overlay"; reject_table "new-overlay"; reject_publish "new-overlay"; + reject_lang "new-overlay"; + reject_lang_sub "new-overlay"; print_string new_overlay_template; exit 0 + | [ "lang" ] -> ( + reject_overlays_for "lang" overlays; + reject_emit "lang"; + reject_table "lang"; + reject_publish "lang"; + reject_lang "lang"; + if show then begin + Printf.eprintf "colitur: --show has no effect on `lang`; refusing rather than ignoring it\n"; + exit 2 + end; + match (list, dump, check) with + | true, None, None -> lang_list () + | false, Some code, None -> lang_dump code + | false, None, Some path -> lang_check path + | false, None, None -> + Printf.eprintf "colitur: lang requires one of --list, --dump CODE or --check FILE\n"; + exit 2 + | _ -> + Printf.eprintf "colitur: lang takes only one of --list, --dump CODE or --check FILE\n"; + exit 2) + | [ "config" ] -> + (* Unlike every other subcommand's own rejector, `config --show` + deliberately ACCEPTS --lang/--template/--format/--overlay: they + are the very settings it previews the resolution of (so + `config --show --lang fr` reports exactly what a real `--lang + fr` run on any other command would resolve to), so none of + [reject_lang]/[reject_overlays_for]/the format half of + [reject_emit] apply here. Everything with no meaning for a + config preview is still refused, not silently ignored. *) + reject_table "config"; + reject_publish "config"; + if from_y <> None || to_y <> None || dtstamp <> None then begin + Printf.eprintf + "colitur: --from/--to/--dtstamp have no effect on `config`; refusing rather than ignoring them\n"; + exit 2 + end; + if raw then begin + Printf.eprintf "colitur: --raw has no effect on `config`; refusing rather than ignoring it\n"; + exit 2 + end; + if dump <> None || check <> None || list then begin + Printf.eprintf + "colitur: --dump/--check/--list have no effect on `config`; refusing rather than ignoring them\n"; + exit 2 + end; + if not show then begin + Printf.eprintf "colitur: config requires --show\n"; + exit 2 + end; + config_show ~lang_flag:lang ~template_flag:template ~format_flag:format ~overlays_flag:overlays + config | [ "day"; ys ] -> reject_emit "day"; reject_table "day"; reject_publish "day"; - with_year ys (day_report ~overlays) + reject_lang_sub "day"; + with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays) | [ "readings"; ys ] -> reject_emit "readings"; reject_table "readings"; reject_publish "readings"; - with_year ys (readings_report ~overlays) + reject_lang_sub "readings"; + with_year ys (readings_report ~lang:(resolved_lang ()) ~overlays:effective_overlays) | [ "emit" ] -> ( reject_table "emit"; reject_publish "emit"; - match format with + reject_lang_sub "emit"; + match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with | None -> Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; exit 2 @@ -1422,12 +1894,16 @@ let () = Printf.eprintf "colitur: emit requires --from YEAR and --to YEAR\n"; exit 2 | Some from_ys, Some to_ys -> + let lang_t = resolved_lang () in with_year from_ys (fun from_y -> - with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y)))) + with_year to_ys (fun to_y -> + emit_report ~lang:lang_t ~format ~overlays:effective_overlays ~dtstamp ~from_y + ~to_y)))) | [ ("table" | "render") as cmd ] -> ( reject_emit cmd; reject_publish cmd; - match template with + reject_lang_sub cmd; + match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with | None -> Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; exit 2 @@ -1436,11 +1912,15 @@ let () = | None -> Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; exit 2 - | Some ys -> with_year ys (fun y -> table_report ~template ~flavour_opt:flavour ~overlays y) + | Some ys -> + let lang_t = resolved_lang () in + with_year ys (fun y -> + table_report ~lang:lang_t ~template ~flavour_opt:flavour ~overlays:effective_overlays y) )) | [ "publish" ] -> ( reject_table "publish"; reject_format_for "publish" format; + reject_lang_sub "publish"; match out with | None -> Printf.eprintf "colitur: publish requires --out DIR\n"; @@ -1451,6 +1931,7 @@ let () = Printf.eprintf "colitur: publish requires --from YEAR and --to YEAR\n"; exit 2 | Some from_ys, Some to_ys -> + let lang_t = resolved_lang () in with_year from_ys (fun from_y -> with_year to_ys (fun to_y -> (* [publish_report] writes many files across a whole @@ -1468,7 +1949,10 @@ let () = (stdlib, not the Unix module) wraps the same underlying errno in [Sys_error] instead -- both are real on this path, so both are caught. *) - try publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune with + try + publish_report ~lang:lang_t ~from_y ~to_y ~out ~overlays:effective_overlays + ~dtstamp ~prune + with | Unix.Unix_error (e, fn, arg) -> Printf.eprintf "colitur: %s: %s: %s\n" fn arg (Unix.error_message e); exit 2 diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml index 4e06eae..b212930 100644 --- a/lib/render/emit_ics.ml +++ b/lib/render/emit_ics.ml @@ -57,9 +57,18 @@ let event b ~rite ~dtstamp d = if iso <> "" then begin (* [name] is the view's own resolved display string (view.ml) -- no further fallback needed here: under [Lang.raw] it already equals - [slug], which is what a miss used to require picking by hand. *) + [slug], which is what a miss used to require picking by hand. + + [rank_name]/[colour_name], not the bare [rank]/[colour]: those two + carry the kernel's own unlocalised strings ("class-1", "white"), + exactly the raw-slug shape [name] itself moved away from -- SUMMARY + is a human-facing calendar entry, and printing "(class-1, white)" + beside a properly resolved name (e.g. "II classis, albus") was the + one field this emitter had not yet been updated to localise; the + JSON/CSV/XML emitters already expose both pairs and a template author + already has to choose the localised one deliberately. *) let name = s d "name" in - let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in + let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank_name") (s d "colour_name") in let desc = String.concat "\n" (List.filter (fun x -> x <> "") diff --git a/man/colitur.1 b/man/colitur.1 index 005cc0a..b9ce1d1 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -9,8 +9,9 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .B colitur .BR day | readings .I YEAR -.BI \-\-overlay " FILE" -.RI [ ... ] +.RB [ \-\-overlay " FILE" " ...]" +.RB [ \-\-lang " CODE\(brFILE" ] +.RB [ \-\-raw ] .br .B colitur .B emit @@ -19,6 +20,8 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .BI \-\-to " YEAR" .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-dtstamp " STAMP" ] +.RB [ \-\-lang " CODE\(brFILE" ] +.RB [ \-\-raw ] .br .B colitur .BR table | render @@ -26,6 +29,8 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .BI \-\-template " FILE" .RB [ \-\-flavour " FLAVOUR" ] .RB [ \-\-overlay " FILE" " ...]" +.RB [ \-\-lang " CODE\(brFILE" ] +.RB [ \-\-raw ] .br .B colitur .B publish @@ -35,6 +40,16 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-prune ] .RB [ \-\-dtstamp " STAMP" ] +.RB [ \-\-lang " CODE\(brFILE" ] +.RB [ \-\-raw ] +.br +.B colitur +.B lang +.RB { \-\-list | \-\-dump " CODE" | \-\-check " FILE" } +.br +.B colitur +.B config +.B \-\-show .br .B colitur .BR \-h | \-\-help @@ -145,6 +160,41 @@ validate a calendar against the rubrics, and cannot: see .B OVERLAYS below. .TP +.B lang \-\-list +List the language files this build can find, and each one's own declared +.BR fallback , +if any. +.TP +.BI lang " " \-\-dump " CODE" +Print the named language's full key set, in INI form, to standard output +\(em a starting point for a new translation, or a way to diff two versions +of one. See +.B NAMING +below. +.TP +.BI lang " " \-\-check " FILE" +Report what a language file is +.B MISSING +(a real slug the engine can produce with no entry for it) and, separately, +any entry naming a slug that does not exist at all \(em a typo, which would +otherwise be silently dead. Exits +.B 1 +if anything is unknown, so it composes into a Makefile or a pre\-commit +hook, the same way +.B check +does for an overlay. See +.B NAMING +below. +.TP +.B config \-\-show +Print every effective setting \(em +.IR lang ", " overlay ", " template ", " format +\(em its resolved value, and where it came from: +.BR flag ", " config " or " default . +See +.BR colitur\-config (5) +for the config file's location and precedence in full. +.TP .BI \-\-overlay " FILE" Apply a user calendar on top of the shipped one. Repeatable and ordered; .BR day ", " readings ", " emit ", " table ", " render " and " publish @@ -222,6 +272,31 @@ own extension. See .B RENDERING below. .TP +.BI \-\-lang " CODE\(brFILE" +.RB ( day ", " readings ", " emit ", " table ", " render " and " publish " only)" +Resolve display names through this language instead of the default. +.I CODE +(e.g. +.BR la ", " en ) +is looked up as a file in the installed language directory; +a value containing +.B / +or ending +.B .ini +is read as a literal path instead. Default +.BR la , +overridable by a config file. An unknown language is a hard error naming +what is available, never a silent fallback to Latin. See +.B NAMING +below. +.TP +.B \-\-raw +.RB ( day ", " readings ", " emit ", " table ", " render " and " publish " only)" +Restore the pre\-naming output: every display name equals its bare machine +slug. See +.B NAMING +below. +.TP .BR \-h ", " \-\-help Print a usage summary to standard output and exit 0. .TP @@ -231,7 +306,7 @@ Print the version and exit 0. .SS day .RS .nf -date weekday season week slug rank colour [+commemoration ...] +date weekday season week slug rank colour [+commemoration ...] [name] .fi .RE .PP @@ -239,19 +314,40 @@ Space\-separated, with one .BI + slug suffix per admitted commemoration. A .B \- -in the week column means the day carries no week number. +in the week column means the day carries no week number. The resolved +display +.I name +(see +.B NAMING +below) is appended LAST, after any commemorations, rather than substituted +for +.I slug +above: a name may itself contain spaces, and inserting it earlier in the row +would break every fixed\-position field that follows it \(em the same +mechanical reason +.B readings +is a separate command rather than extra columns on +.B day +(see below). It is present only when it differs from +.I slug +already shown; under +.BR \-\-raw , +or a language with no entry for that particular day, the trailing field is +simply absent \(em not merely empty \(em which is what makes +.B \-\-raw +byte\-identical to this program's pre\-naming output. .RS .nf -2026\-04\-05 sunday paschaltide 1 ef\-easter\-sunday class\-1 white -2026\-11\-02 monday time\-after\-pentecost 23 commemoration\-of\-all\-souls class\-1 black -2057\-03\-26 monday lent 3 annunciation\-of\-the\-blessed\-virgin\-mary class\-1 white +ef\-lent\-3\-monday +2026\-04\-05 sunday paschaltide 1 ef\-easter\-sunday class\-1 white Dominica Resurrectionis +2026\-11\-02 monday time\-after\-pentecost 23 commemoration\-of\-all\-souls class\-1 black In Commemoratione Omnium Fidelium Defunctorum +2057\-03\-26 monday lent 3 annunciation\-of\-the\-blessed\-virgin\-mary class\-1 white +ef\-lent\-3\-monday In Annuntiatione B. Mariae Virg. .fi .RE .SS readings .RS .nf -date slug | Epistle | Gospel +date slug | Epistle | Gospel [| name] .fi .RE .PP @@ -265,12 +361,20 @@ rather than extra columns on .BR day : appended there, no field number could recover where the Epistle ended. A .B \- -in either citation field means none was resolved. +in either citation field means none was resolved. The resolved display +.I name +is appended as a fourth, +.RB \(lq " | " \(rq \-delimited +field on the same "present only when it differs from +.IR slug "" \(cq +terms as +.BR day 's +own trailing field, above. .RS .nf -2026\-12\-25 ef\-nativity | Heb 1:1\-12 | John 1:1\-14 -2038\-03\-06 sts\-felicitas\-perpetua | Ecclus 51:1\-8, 12 | Matt 13:44\-52 +2026\-12\-25 ef\-nativity | Heb 1:1\-12 | John 1:1\-14 | In Nativitate Domini +2038\-03\-06 sts\-felicitas\-perpetua | Ecclus 51:1\-8, 12 | Matt 13:44\-52 | Ss. Perpetuae et Felicitatis Mm. .fi .RE .PP @@ -279,12 +383,17 @@ Both reports are one line per day and ordered by date, so they compose with .BR awk (1) and .BR join (1) -in the ordinary way. +in the ordinary way. Pass +.B \-\-raw +to restore the pre\-naming byte\-exact output of either report \(em no +trailing field at all \(em for a script written against it before this +feature existed. .SH EMIT .BI "colitur emit " \-\-format " FMT " \-\-from " YEAR " \-\-to " YEAR" renders the same resolved day \(em season, week, slug, rank, colour, -subject, Latin and English names, citations, commemorations \(em through one -of five emitters, for every day in the inclusive civil\-year range +subject, the resolved display name in the active language, citations, +commemorations \(em through one of five emitters, for every day in the +inclusive civil\-year range .IR FROM .. TO . Every emitter consumes one shared view of the data, so all five describe exactly the same fields. @@ -300,10 +409,20 @@ behave as expected. .nf .B colitur emit \-\-format csv \-\-from 2026 \-\-to 2026 | head \-2 -date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms -2026\-01\-01,ef,christmastide,,ef\-circumcision,class\-1,white,temporal,,,Titus 2:11\-15,Luke 2:21, +date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms +2026\-01\-01,ef,christmastide,Tempus Nativitatis,,ef\-circumcision,In Octava Nativitatis Domini,Feria V,class\-1,I classis,white,albus,temporal,Titus 2:11\-15,Luke 2:21, .fi .RE +.PP +.IR name ", " season_name ", " rank_name " and " colour_name +are the resolved display strings in the active language (see +.B NAMING +below); +.IR slug ", " season ", " rank " and " colour +stay the kernel's own unlocalised keys, unaffected by +.BR \-\-lang / \-\-raw , +so a script can key off the stable machine value while a human reads the +localised one beside it. .TP .B json One JSON object per requested year, concatenated. Shape pinned by @@ -437,8 +556,9 @@ out of its flavour's escaping. .PP The template renders against the same schema .B emit -uses (season, week, slug, rank, colour, subject, names, citations, -commemorations), reshaped for two artefacts from one model: a flat booklet +uses (season, week, slug, rank, colour, subject, the resolved display name, +citations, commemorations), reshaped for two artefacts from one model: a +flat booklet (the .B days list, one entry per day of the year) and a month grid (the @@ -455,10 +575,24 @@ for the full syntax, the escaping table per flavour, the complete view\-model field reference, and \(em before writing a template of any complexity \(em its .B SCOPE AND LOOKUP -section: an inner key silently loses to an outer key of the same name (a bare -.B {{name.la}} -inside a day resolves to the enclosing MONTH's name, not the day's own), -which has produced wrong output in this project's own templates. +section: a +.B month +and a +.B week +both carry a +.I num +field, and a bare +.B {{num}} +read from inside +.B {{#days}} +silently climbs to the enclosing week's own value, not the month's. A +similar hazard around +.B name +existed before the view model's naming rework and is now unrepresentable +\(em +.I name +is a plain resolved string, with no dotted path left for a partial match to +fall back through. .SS Flavours .BI \-\-flavour controls how interpolated @@ -718,6 +852,131 @@ calendar. None of them can vouch for a file you supply. A directive naming a slug that does not exist prints a warning to standard error and the run continues, so a typo in a local calendar is visible rather than silent; a file that fails to load is fatal. +.SH NAMING +.TP +.BI \-\-lang " CODE\(brFILE" +Resolve every display name \(em a day's +.IR name , +its localised +.IR weekday / rank_name / colour_name / season_name , +each month's own name, and the fixed +.I term +vocabulary a template routes through \(em through this language instead of +the default. Accepted on +.BR day ", " readings ", " emit ", " table ", " render " and " publish ; +refused elsewhere, the same discipline +.B \-\-overlay +gets. +.I CODE +(e.g. +.BR la ", " en ) +is looked up as +.IR <lang\-dir> / CODE .ini ; +a value containing +.B / +or ending +.B .ini +is read as a literal file path instead. Default +.BR la , +overridable by +.I lang +in the config file (see +.BR colitur\-config (5)), +itself overridden by +.BR \-\-lang . +.PP +.B An unknown language is a hard error naming what is available, never a +.B silent fallback to Latin: +a booklet quietly printed in the wrong language is worse than one that +refuses to print. +.RS +.nf + +.B colitur day 2027 \-\-lang xx +colitur: no language "xx" (looked in .../share/colitur/lang); try: colitur lang \-\-list +.fi +.RE +.PP +A language file may declare +.B fallback " = " CODE +in its +.I [meta] +section (both shipped files show the shape; +.I lang/en.ini +declares +.BR "fallback = la" ). +A slug the active language does not itself name still resolves through the +fallback chain, so a partial translation is usable from its first line +rather than only once it is complete. +.TP +.B \-\-raw +Restore every command's pre\-naming output: a day's +.I name +(and every other localised field the +.B emit +schema carries) equals the bare machine +.IR slug , +exactly as if no language had ever been resolved. This is not a special +case threaded through the naming code \(em the identity table under +.RB ( raw ) +is an ordinary language table like any other, under which every lookup +echoes its key back unchanged, so +.B \-\-raw +and a real language file share the same code path throughout. +.B day +and +.B readings +under +.B \-\-raw +are byte\-identical to this program's own pre\-naming output; every existing +script built against that output therefore needs one flag, not a rewrite. +.TP +.B lang \-\-list +.TQ +.BI lang " " \-\-dump " CODE" +.TQ +.BI lang " " \-\-check " FILE" +What make "anyone can write a language file" true rather than merely +permitted \(em the same idea the overlay system already established with +.B new\-overlay +and +.BR check . +.RS +.nf + +.B colitur lang \-\-dump la > my\-lang.ini +.B $EDITOR my\-lang.ini +.B colitur lang \-\-check my\-lang.ini +.B colitur day 2026 \-\-lang ./my\-lang.ini +.fi +.RE +.PP +.B \-\-check +reports what is +.B MISSING +(a real slug the engine can produce, with no entry for it in the file) and, +separately, any entry naming a slug that does not exist at all \(em a typo, +which would otherwise be silently dead, its author never learning why their +own name never appears. Exits +.B 1 +if anything is unknown, so it composes into a Makefile or a pre\-commit +hook. The reference slug set is the same one +.BR "colitur lang \-\-check" 's +own coverage test walks: the observed office AND every commemoration and +transfer the engine can produce, over a fixed multi\-year window \(em not +merely a hand\-picked sample. +.TP +.B config \-\-show +Print every effective setting \(em +.IR lang ", " overlay ", " template ", " format +\(em its resolved value, and where it came from: +.BR flag ", " config " or " default , +via the same resolver every other command uses (there is deliberately no +separate "provenance" function, so the two cannot disagree). Also prints +the config file's own path and whether it exists. See +.BR colitur\-config (5) +for the file's location, its precedence in full, and every setting it +recognises. .SH ENVIRONMENT .TP .B COLITUR_DATA_DIR @@ -790,6 +1049,17 @@ Days carrying at least one commemoration: .fi .RE .PP +Names are Latin by default; render in English instead, or fall back to the +pre\-naming, slugs\-only output a script written before this feature existed +still expects: +.RS +.nf + +.B colitur day 2026 \-\-lang en | head \-1 +.B colitur day 2026 \-\-raw | head \-1 +.fi +.RE +.PP A local calendar on top of the universal one: .RS .nf @@ -858,8 +1128,12 @@ shapes and worked examples. for the template format used by .BR table ", " render " and " publish \(em the four syntax forms, the six flavours and their escaping, the full -view\-model field reference, and the scope\-shadowing hazard a template author -will hit. +view\-model field reference, and the one remaining scope hazard a template +author can still hit. +.PP +.BR colitur\-config (5) +for the config file's location, the flag > config > default precedence, and +every setting it recognises. .PP .BR lectio (1) .SH LICENSE @@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2): No/garbage arguments give a usage error (exit 2): $ colitur - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The EF temporal cycle for a year, one line per day: @@ -53,20 +53,20 @@ Easter is the observed day exactly once, and carries no commemoration (an impeded I class day admits at most one PRIVILEGED commemoration, RG 111, and nothing outranks Easter to be impeded by it in the first place): - $ colitur day 2026 | grep -c '^2026-04-05 ' + $ colitur day 2026 --raw | grep -c '^2026-04-05 ' 1 - $ colitur day 2026 | grep '^2026-04-05 ' + $ colitur day 2026 --raw | grep '^2026-04-05 ' 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white Ash Wednesday: I class (RG 91 entry 7), violet, no numbered week (it falls 4 days before Lent I's own origin -- rite_ef/temporal_ef.ml's [week]): - $ colitur day 2026 | grep '^2026-02-18 ' + $ colitur day 2026 --raw | grep '^2026-02-18 ' 2026-02-18 wednesday lent - ef-ash-wednesday class-1 violet Christmas: I class, white (RG 91 entry 1): - $ colitur day 2026 | grep '^2026-12-25 ' + $ colitur day 2026 --raw | grep '^2026-12-25 ' 2026-12-25 friday christmastide - ef-nativity class-1 white All Souls (2 Nov, RG 91 entry 8) end to end: 2 Nov 2025 is a Sunday (verified @@ -77,11 +77,11 @@ itself is not re-asserted here, already covered by test_temporal_ef.ml); RG 95 then transfers All Souls (I class, not a Sunday, not a vigil) and RG 96 places it on 3 Nov, the next day that is not I or II class: - $ colitur day 2025 | grep -c 'commemoration-of-all-souls' + $ colitur day 2025 --raw | grep -c 'commemoration-of-all-souls' 1 - $ colitur day 2025 | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / <week> ef-time-after-pentecost-sunday-<n> /' + $ colitur day 2025 --raw | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / <week> ef-time-after-pentecost-sunday-<n> /' 2025-11-02 sunday time-after-pentecost <week> ef-time-after-pentecost-sunday-<n> class-2 green - $ colitur day 2025 | grep '^2025-11-03 ' + $ colitur day 2025 --raw | grep '^2025-11-03 ' 2025-11-03 monday time-after-pentecost 21 commemoration-of-all-souls class-1 black (week 21: Pentecost 2025 is 8 June (colitur easter 2025); 8 Jun - 3 Nov is @@ -93,9 +93,9 @@ All Souls observed directly on 2 Nov in a year where it does not fall on a Sunday: 2 Nov 2026 is a Monday (1 Jan 2026 is a Thursday, same day-of-year 306 offset, (4+305) mod 7 = 1 = Monday): - $ colitur day 2026 | grep -c 'commemoration-of-all-souls' + $ colitur day 2026 --raw | grep -c 'commemoration-of-all-souls' 1 - $ colitur day 2026 | grep '^2026-11-02 ' + $ colitur day 2026 --raw | grep '^2026-11-02 ' 2026-11-02 monday time-after-pentecost 23 commemoration-of-all-souls class-1 black (week 23: Pentecost 2026 is 24 May (colitur easter 2026); 24 May - 2 Nov is @@ -109,9 +109,9 @@ RG 96 target -- not the Monday after Low Sunday -- is what governs, since the general walk never crosses Easter. Before this fix the unconditional reading sent it to 30 April (Easter + 8) instead: - $ colitur day 2057 | grep '^2057-03-26 ' + $ colitur day 2057 --raw | grep '^2057-03-26 ' 2057-03-26 monday lent 3 annunciation-of-the-blessed-virgin-mary class-1 white +ef-lent-3-monday - $ colitur day 2057 | grep -c 'annunciation-of-the-blessed-virgin-mary' + $ colitur day 2057 --raw | grep -c 'annunciation-of-the-blessed-virgin-mary' 1 A year outside the supported domain is rejected (exit 2): @@ -138,7 +138,7 @@ sabbato", assigned to Christmas through the Purification (RG 309(a)). It is NOT the Christmastide feria's Mass, which is what colitur said before that rule was implemented. - $ colitur readings 2026 | head -3 + $ colitur readings 2026 --raw | head -3 2026-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 2026-01-02 ef-christmas-1-friday | Titus 2:11-15 | Luke 2:21 2026-01-03 ef-christmas-1-saturday | Titus 3:4-7 | Luke 2:15-20 @@ -160,16 +160,16 @@ test_golden.ml pins against the photographic scans (each literal there was read out of the Missal before being compared with colitur -- these assert that the CLI path agrees with the library path, nothing more): - $ colitur readings 2038 | grep '^2038-03-08 ' + $ colitur readings 2038 --raw | grep '^2038-03-08 ' 2038-03-08 john-of-god | Ecclus 31:8-11 | Matt 22:34-46 - $ colitur readings 2026 | grep '^2026-02-23 ' + $ colitur readings 2026 --raw | grep '^2026-02-23 ' 2026-02-23 ef-lent-1-monday | Ezech 34:11-16 | Matt 25:31-46 - $ colitur readings 2025 | grep '^2025-12-01 ' + $ colitur readings 2025 --raw | grep '^2025-12-01 ' 2025-12-01 ef-advent-1-monday | Rom 13:11-14 | Luke 21:25-33 - $ colitur readings 2038 | grep '^2038-03-06 ' + $ colitur readings 2038 --raw | grep '^2038-03-06 ' 2038-03-06 sts-felicitas-perpetua | Ecclus 51:1-8, 12 | Matt 13:44-52 The Advent line above is the ferial resumption (chain step 3) and is the one @@ -190,7 +190,7 @@ rather than the guarantee itself: `colitur day`'s own format is unaffected by any of the above: - $ colitur day 2026 | head -1 + $ colitur day 2026 --raw | head -1 2026-01-01 thursday christmastide - ef-circumcision class-1 white A year outside the supported domain is rejected (exit 2): @@ -280,14 +280,14 @@ four while appearing merely to add a local feast. A fixed-date local patron, observed on its own day: - $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11 ' + $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp --raw | grep '^2026-07-11 ' 2026-07-11 saturday time-after-pentecost 6 example-local-patron class-3 white +pius-i A MOVABLE one -- the capability the Date_spec work existed to unlock. "The first Sunday of October" is the shape a patronal or dedication feast usually takes, and it lands on a different civil date every year: - $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp | grep 'example-dedication'; done + $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp --raw | grep 'example-dedication'; done 2026-10-04 sunday time-after-pentecost 19 example-dedication class-1 white +ef-time-after-pentecost-sunday-19 2027-10-03 sunday time-after-pentecost 20 example-dedication class-1 white +ef-time-after-pentecost-sunday-20 2028-10-01 sunday time-after-pentecost 17 example-dedication class-1 white +ef-time-after-pentecost-sunday-17 @@ -295,7 +295,7 @@ takes, and it lands on a different civil date every year: The shipped calendar is untouched without the flag -- the same date, no local feast, the ordinary Sunday restored: - $ colitur day 2026 | grep '^2026-10-04 ' + $ colitur day 2026 --raw | grep '^2026-10-04 ' 2026-10-04 sunday time-after-pentecost 19 ef-time-after-pentecost-sunday-19 class-2 green And the shipped overlay's own entries survive alongside the user's, which is @@ -327,14 +327,14 @@ A flag needing a value, given none: $ colitur day 2026 --overlay colitur: --overlay needs a file path - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help) [2] An unknown option is rejected rather than treated as a positional word: $ colitur day 2026 --diocese colitur: unknown option --diocese - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The shipped example overlay is runnable documentation, and it must actually @@ -383,7 +383,7 @@ own output before emitting it. The full pipeline, INI to a resolved day: p.sexp: ok -- overlay my-parish, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit every directive found its target add our-patron - $ colitur day 2026 --overlay p.sexp | grep '^2026-07-11' + $ colitur day 2026 --overlay p.sexp --raw | grep '^2026-07-11' 2026-07-11 saturday time-after-pentecost 6 our-patron class-3 white +pius-i What the INI form cannot express is refused by name, not dropped silently: @@ -413,7 +413,7 @@ An overlay feast competes under the ordinary rules: the Benedictine Transitus is I class and takes 21 March, with the Lenten feria commemorated rather than displaced silently. - $ colitur day 2026 --overlay ben.sexp | grep '^2026-03-21' + $ colitur day 2026 --overlay ben.sexp --raw | grep '^2026-03-21' 2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday CSV emits a header and one row per day: @@ -501,12 +501,90 @@ silently ignored, the same discipline --overlay already gets: day and readings are untouched: - $ colitur day 2027 | head -1 + $ colitur day 2027 --raw | head -1 2027-01-01 friday christmastide - ef-circumcision class-1 white + $ colitur readings 2027 --raw | head -1 + 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 + +Names are Latin by default (Task 6): `day` gains a trailing display name and +`readings` a trailing " | name" field, appended rather than substituted into +the slug's own position -- a name contains spaces, and inserting it there +would break every fixed-position field after it for anyone parsing this row +by column, the same reason `readings` is a separate command in the first +place. 1 January 2027 is the Circumcision, kept within the Nativity octave: + + $ colitur day 2027 | head -2 + 2027-01-01 friday christmastide - ef-circumcision class-1 white In Octava Nativitatis Domini + 2027-01-02 saturday christmastide - ef-christmas-1-saturday class-4 white Officium sanctae Mariae in sabbato + $ colitur readings 2027 | head -1 + 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 | In Octava Nativitatis Domini + +--raw restores the old byte-exact output -- no trailing field at all, not +merely an empty one, because [Lang.raw] is the identity table (lang.mli): +the resolved name equals the slug already printed, so day_line/readings_line +omit the field entirely rather than repeat the slug a second time. This is +not a special case keyed on --raw itself; it is a direct consequence of the +identity table, verified against the pre-branch binary in the task report +rather than merely asserted: + + $ colitur day 2027 --raw | head -1 + 2027-01-01 friday christmastide - ef-circumcision class-1 white + + $ colitur readings 2027 --raw | head -1 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 +A language file by path (--lang accepts a bare code OR a file path -- a +value containing '/' or ending ".ini" is read literally rather than looked +up in the installed language directory). The synthetic file below declares +`fallback = la`, so every slug it does not name itself still resolves +through the chain to la.ini's real name, and only the one it DOES name +shows the override: + + $ printf '[meta]\nlang = xx\nfallback = la\n[celebration]\nef-circumcision = TEST FEAST\n' > lang-xx.ini + $ colitur day 2027 --lang ./lang-xx.ini | head -1 + 2027-01-01 friday christmastide - ef-circumcision class-1 white TEST FEAST + +An unknown language is a usage error naming what is available, never a +silent fallback to Latin: a booklet quietly printed in the wrong language is +worse than one that refuses to print. The exact "(looked in ...)" path is +not pinned here -- it names the cram sandbox's own build tree, the same +reason the data-directory fallback test above is not reproduced literally +either: + + $ colitur day 2027 --lang nonexistent 2>&1 | grep -c 'no language "nonexistent"' + 1 + $ colitur day 2027 --lang nonexistent >/dev/null 2>&1; echo "exit=$?" + exit=2 + +A malformed language file is an error, not a crash: + + $ printf '[celebration\nbroken' > bad-lang.ini + $ colitur day 2027 --lang ./bad-lang.ini + colitur: ./bad-lang.ini: line 1: "[celebration" looks like a section header but does not end with ']' + [2] + +--lang/--raw have no effect on a command that reads no display names at +all, refused rather than silently ignored, the same discipline --overlay +already gets: + + $ colitur easter 2026 --lang la + colitur: --lang/--raw have no effect on `easter`; refusing rather than ignoring them + [2] + + $ colitur temporal 2026 --raw + colitur: --lang/--raw have no effect on `temporal`; refusing rather than ignoring them + [2] + +The ICS SUMMARY line is localised too (found while reviewing the published +site: it used to read the kernel's own unlocalised "(class-1, white)" where +the JSON beside it already said "II classis"/"albus" -- emit_ics.ml was +reading the wrong pair of view fields): + + $ colitur emit --format ics --from 2027 --to 2027 | grep '^SUMMARY' | head -1 + SUMMARY:In Octava Nativitatis Domini (I classis\, albus) + A minimal inline template renders -- table computes and renders in one process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday falls on the 3rd, not the 2nd, that year): @@ -1098,3 +1176,125 @@ over-broad: it still prunes normally when stale. $ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028 pruned-2028 + +`colitur lang` and `colitur config` (Task 7) -- what makes "anyone can write +a language file" true rather than merely permitted, the same idea the +overlay system already established with `new-overlay` and `check`. + +`--list` shows what language files this build can find: + + $ colitur lang --list + en (falls back to la) + la + +`--dump CODE` scaffolds a complete file on stdout, sections in a fixed order +so two dumps of the same table are byte-identical: + + $ colitur lang --dump la > d.ini + $ head -4 d.ini + [meta] + lang = la + + [celebration] + $ grep -c '^ef-epiphany ' d.ini + 1 + +A dump round-trips -- it just IS la.ini's own content, so it covers every +slug the engine can produce over 2020-2045: + + $ colitur lang --check d.ini + d.ini: 725 of 725 celebrations named, 0 missing, 0 unknown + +`--check` reports what is MISSING (a real slug with no entry): + + $ printf '[meta]\nlang = zz\n[celebration]\nef-epiphany = Test\n' > partial.ini + $ colitur lang --check partial.ini | tail -1 + partial.ini: 1 of 725 celebrations named, 724 missing, 0 unknown + +`--check` REJECTS an unknown slug (exit 1), so a typo is visible rather than +silently dead -- its author would otherwise never learn why the name they +wrote never appears: + + $ printf '[meta]\nlang = zz\n[celebration]\nnot-a-real-slug = X\n' > typo.ini + $ colitur lang --check typo.ini | grep 'unknown slug' + unknown slug: not-a-real-slug + $ colitur lang --check typo.ini >/dev/null 2>&1; echo "exit=$?" + exit=1 + +`lang` requires exactly one of --list/--dump/--check: + + $ colitur lang + colitur: lang requires one of --list, --dump CODE or --check FILE + [2] + + $ colitur lang --list --dump la + colitur: lang takes only one of --list, --dump CODE or --check FILE + [2] + +`--dump`/`--check`/`--list`/`--show` have no effect on any other command, +refused rather than silently ignored, the same discipline --overlay/--lang +already get: + + $ colitur day 2027 --dump la + colitur: --dump/--check/--list/--show have no effect on `day`; refusing rather than ignoring them + [2] + +`config --show` reports each effective setting, its resolved value, and +where it came from -- flag, config or default -- via Config.resolve; there +is deliberately no separate provenance function. Run through an isolated +XDG_CONFIG_HOME so this does not depend on whatever the person running the +suite happens to have in their own home directory (the same reason the +data-directory fallback case earlier in this file is not reproduced +literally either): + + $ XDG_CONFIG_HOME=xdg-test colitur config --show + config file: xdg-test/colitur/config.ini (not found) + lang la (default) + template (none) (default) + format (none) (default) + overlay (none) (default) + +A flag on the SAME command line previews exactly what it would resolve to +on any other command -- `overlay` has no single scalar value, so it is +listed one line per effective entry instead: + + $ XDG_CONFIG_HOME=xdg-test colitur config --show --lang fr --overlay mine.sexp + config file: xdg-test/colitur/config.ini (not found) + lang fr (flag) + template (none) (default) + format (none) (default) + overlay mine.sexp (flag) + +A real config file supplies a default that a command with no explicit flag +then uses. An unrecognised key and an unrecognised section are each warned +about and ignored, never silently dropped and never fatal on their own -- +two separate warnings, so a misspelled section (`[deafults]`) reads +differently from a misspelled key inside a recognised one: + + $ mkdir -p xdg-test/colitur + $ printf '[defaults]\nlang = en\noverlay = /nonexistent/parish.sexp\nbogus = 1\n\n[deafults]\nlang = xx\n' > xdg-test/colitur/config.ini + $ XDG_CONFIG_HOME=xdg-test colitur config --show + config file: xdg-test/colitur/config.ini (exists) + lang en (config) + template (none) (default) + format (none) (default) + overlay /nonexistent/parish.sexp (config) + colitur: xdg-test/colitur/config.ini: unknown setting "bogus" (ignored) + colitur: xdg-test/colitur/config.ini: unknown section [deafults] (ignored) + +`config` requires --show: + + $ colitur config + colitur: config requires --show + [2] + +A config file that EXISTS but fails to parse is fatal -- a config the user +wrote and colitur cannot honour is not something to carry on past silently +-- and this is true for every command, not only `config` itself, because +the file is loaded once, unconditionally, before dispatch: + + $ mkdir -p xdg-bad/colitur + $ printf '[defaults\nbroken' > xdg-bad/colitur/config.ini + $ XDG_CONFIG_HOME=xdg-bad colitur day 2027 --raw + colitur: xdg-bad/colitur/config.ini: line 1: "[defaults" looks like a section header but does not end with ']' + [2] |
