From 0f94c388880972f8e665ed01a6b38190bcc61499 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 09:28:08 +0200 Subject: feat(cli): --lang and --raw, colitur lang, colitur config Names are Latin by default now; --raw restores the old byte-exact output, verified against a pre-branch binary built from a clean HEAD checkout, not merely asserted. day and readings gain a trailing display-name field (day's own name suffix, readings' own " | name" suffix) rather than a substitution in the slug's existing position: a name contains spaces and would otherwise break the space-separated columns, the same reasoning that already keeps readings a separate command. The field is present only when it differs from the slug already shown, which is a direct consequence of Lang.raw being the identity table (no lookup ever misses), not a special case threaded through day_line/readings_line -- under --raw, or any language with no entry for a given day, the trailing field is simply absent, byte for byte what this project printed before naming existed. 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. A declared fallback still chains, so a partial translation shows its fallback language rather than slugs. lang_dir mirrors data_dir's probe order exactly -- installed prefix, then build tree -- because an installed binary that finds calendar data but no language files is the failure this would otherwise ship. Config (lang/overlay/template/format) is loaded once, unconditionally, before dispatch: a config file that exists but fails to parse is fatal on every command, not only the ones that consult it, because a config the user wrote and colitur cannot honour is not something to silently carry on past. An unknown key or unknown section is reported and ignored, worded differently so a misspelled section reads differently from a misspelled key inside a recognised one. overlay's config default applies only when zero --overlay flags were given, never merged with a partial CLI list, so precedence stays flag > config > default in one direction throughout. colitur lang --list/--dump/--check and colitur config --show are what make "anyone can write a language file" true rather than merely permitted, the same pattern the overlay system already established with new-overlay and check. --check reports what is missing AND rejects an unknown slug (exit 1), because a key matching nothing is silently dead and its author would otherwise never learn why their own name does not appear. --dump emits sections in a fixed order (Lang.keys' own global sort keeps every section's keys contiguous), so two dumps of the same table are byte-identical. config --show resolves through the same Config.resolve every other command uses -- deliberately no separate provenance function, so the two cannot disagree -- and previews exactly what flags given on the same command line would resolve to anywhere else. One authorized change outside this task's own file list: emit_ics.ml's SUMMARY line was reading the unlocalised rank/colour pair ("class-1, white") beside an already-localised name, where the JSON schema right next to it correctly carries rank_name/colour_name ("II classis"/"albus"). Found while reviewing the published site; fixed at the source. --- bin/main.ml | 628 +++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 556 insertions(+), 72 deletions(-) (limited to 'bin') 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 = \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 the temporal cycle, one line per day colitur day the resolved day identity, one line per day colitur readings the Mass reading citations, one line per day - colitur day|readings --overlay FILE [--overlay FILE ...] + colitur day|readings [--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 + /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 | colitur temporal | colitur day | colitur \ readings | 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 -- cgit v1.3