aboutsummaryrefslogtreecommitdiff
path: root/lib/naming
diff options
context:
space:
mode:
Diffstat (limited to 'lib/naming')
-rw-r--r--lib/naming/config.ml80
-rw-r--r--lib/naming/config.mli49
-rw-r--r--lib/naming/dune3
-rw-r--r--lib/naming/lang.ml109
-rw-r--r--lib/naming/lang.mli60
5 files changed, 301 insertions, 0 deletions
diff --git a/lib/naming/config.ml b/lib/naming/config.ml
new file mode 100644
index 0000000..7d87d62
--- /dev/null
+++ b/lib/naming/config.ml
@@ -0,0 +1,80 @@
+module OI = Colitur_kernel.Overlay_ini
+
+type t = {
+ lang : string option;
+ overlays : string list;
+ template : string option;
+ format : string option;
+ unknown_keys : string list;
+ unknown_sections : string list;
+}
+
+let empty =
+ { lang = None; overlays = []; template = None; format = None; unknown_keys = [];
+ unknown_sections = [] }
+
+let lang t = t.lang
+let overlays t = t.overlays
+let template t = t.template
+let format t = t.format
+let unknown_keys t = t.unknown_keys
+let unknown_sections t = t.unknown_sections
+
+let of_string text =
+ match OI.parse_sections text with
+ | Error e -> Error e
+ | Ok sections ->
+ (* Any section that is not [defaults] is unrecognised -- including a
+ plain typo such as [deafults] -- and must be REPORTED, never
+ silently dropped: that is precisely the failure this feature exists
+ to surface. *)
+ let unknown_sections =
+ List.filter_map
+ (fun (s : OI.section) -> if s.OI.name = "defaults" then None else Some s.OI.name)
+ sections
+ in
+ (* Merge EVERY section named [defaults], not just the first: [lang.ml]'s
+ [of_string] was fixed this morning to fold over all matching
+ sections rather than take [List.find_opt]'s first match, because a
+ hand-edited file WILL grow duplicate headers as a user appends to it
+ over time. Both modules parse the same reader
+ ([Overlay_ini.parse_sections]) over the same file format, so they
+ must not disagree about what a duplicate [section] header means --
+ taking only the first [defaults] block here silently discarded every
+ later one, with nothing pointing back at the parser. Folding a
+ single accumulator across every matching section, in file order,
+ keeps this consistent with the within-section behaviour below (last
+ [k]-match wins): a scalar key repeated across two blocks resolves to
+ the LATER value, and [overlay] keeps accumulating across every
+ block, not only its first. *)
+ let defaults_sections =
+ List.filter (fun (s : OI.section) -> s.OI.name = "defaults") sections
+ in
+ let acc =
+ (* Cons then reverse once at the very end, not `@ [v]` per line: the
+ latter is O(n^2) over the field count, a real hang on a
+ machine-generated file with many overlay lines. Reversing only
+ after every section has been folded (not once per section) is
+ what keeps [overlay] and [unknown_keys] in file order across
+ block boundaries, not merely within one block. *)
+ List.fold_left
+ (fun acc (s : OI.section) ->
+ List.fold_left
+ (fun acc (k, v) ->
+ match k with
+ | "lang" -> { acc with lang = Some v }
+ | "template" -> { acc with template = Some v }
+ | "format" -> { acc with format = Some v }
+ (* accumulates: a user has more than one overlay *)
+ | "overlay" -> { acc with overlays = v :: acc.overlays }
+ | other -> { acc with unknown_keys = other :: acc.unknown_keys })
+ acc s.OI.fields)
+ empty defaults_sections
+ in
+ let acc = { acc with overlays = List.rev acc.overlays; unknown_keys = List.rev acc.unknown_keys } in
+ Ok { acc with unknown_sections }
+
+let resolve ~flag ~config ~default =
+ match flag with
+ | Some v -> (v, "flag")
+ | None -> ( match config with Some v -> (v, "config") | None -> (default, "default"))
diff --git a/lib/naming/config.mli b/lib/naming/config.mli
new file mode 100644
index 0000000..a84f95b
--- /dev/null
+++ b/lib/naming/config.mli
@@ -0,0 +1,49 @@
+(** The config file: what the user wants by default, and where each value came
+ from.
+
+ Owns precedence and provenance and nothing else. Never reads the filesystem
+ -- callers hand it text -- so it is as testable as the language table.
+
+ A config file is OPTIONAL. With none, colitur behaves exactly as it does
+ without this feature, except that names resolve through the default
+ language. *)
+
+type t
+
+val empty : t
+val of_string : string -> (t, string) result
+
+(** [lang], [template] and [format] are each set from a single field. A
+ repeated key is LAST-WINS -- the opposite direction from
+ {!Colitur_kernel.Overlay_ini.get}'s first-wins over the same [section]
+ type -- because the natural reading of a config file a user edited by
+ hand and appended to is "the bottom line is the one that took effect".
+ This holds whether the repeat is within one [\[defaults\]] block or
+ across two of them: every section named [defaults] is merged, not only
+ the first, the same duplicate-section policy {!Lang.of_string} documents
+ for its own sections -- the two modules read the same underlying format
+ and must not disagree about what a repeated header means. *)
+val lang : t -> string option
+
+val overlays : t -> string list
+val template : t -> string option
+val format : t -> string option
+
+(** Keys present in the [\[defaults\]] section that this build does not
+ understand. Reported, never fatal: a config written for a newer colitur
+ must still work on an older one, but silently ignoring a line the user
+ wrote is how a typo becomes invisible. *)
+val unknown_keys : t -> string list
+
+(** Section names other than [\[defaults\]], reported separately from
+ {!unknown_keys} so the CLI can word the two warnings differently (a
+ misspelled section, e.g. [\[deafults\]], versus a misspelled key inside a
+ recognised one). Also never fatal, and never silent: a section this build
+ does not recognise is exactly the highest-value typo this feature exists
+ to catch, because it silently discards the whole section -- [lang] and
+ everything else in it -- with no other way for the user to notice. *)
+val unknown_sections : t -> string list
+
+(** [resolve ~flag ~config ~default] returns [(value, source)] with source one of
+ ["flag"], ["config"], ["default"]. Precedence is flag > config > default. *)
+val resolve : flag:string option -> config:string option -> default:string -> string * string
diff --git a/lib/naming/dune b/lib/naming/dune
new file mode 100644
index 0000000..8af06c5
--- /dev/null
+++ b/lib/naming/dune
@@ -0,0 +1,3 @@
+(library
+ (name colitur_naming)
+ (libraries colitur_kernel))
diff --git a/lib/naming/lang.ml b/lib/naming/lang.ml
new file mode 100644
index 0000000..147a49b
--- /dev/null
+++ b/lib/naming/lang.ml
@@ -0,0 +1,109 @@
+module OI = Colitur_kernel.Overlay_ini
+
+module SM = Map.Make (String)
+
+type table = string SM.t
+
+type t = {
+ code : string;
+ fallback_code : string option;
+ celebration : table;
+ weekday : table;
+ month : table;
+ season : table;
+ rank : table;
+ colour : table;
+ term : table;
+ chain : t option; (** consulted when this table misses *)
+}
+
+let empty_table = SM.empty
+
+let rec lookup t sel key =
+ match SM.find_opt key (sel t) with
+ | Some v -> Some v
+ | None -> ( match t.chain with Some b -> lookup b sel key | None -> None)
+
+(* A miss returns the KEY, never "". See lang.mli for why. *)
+let get t sel key = match lookup t sel key with Some v -> v | None -> key
+
+let celebration t k = get t (fun x -> x.celebration) k
+let season t k = get t (fun x -> x.season) k
+let rank t k = get t (fun x -> x.rank) k
+let colour t k = get t (fun x -> x.colour) k
+let term t k = get t (fun x -> x.term) k
+
+let weekday_key = [| "sunday"; "monday"; "tuesday"; "wednesday"; "thursday"; "friday"; "saturday" |]
+
+(* weekday/month deliberately do NOT go through [get]: their SM key is a
+ presentation detail (an English day-name word for weekday, a numeral for
+ month), not the value a miss should degrade to. [get]'s generic
+ echo-the-search-key fallback would leak that word ("sunday") out of an
+ empty/raw table instead of the documented numeral -- [month] never showed
+ the bug because its key already equals [string_of_int n], but [weekday]
+ did, and it broke [Lang.raw]'s own identity contract (0 = Sunday, not
+ "sunday"). Falling back to [string_of_int n] directly keeps [month]
+ byte-identical and fixes [weekday]. *)
+let weekday t n =
+ if n < 0 || n > 6 then string_of_int n
+ else match lookup t (fun x -> x.weekday) weekday_key.(n) with Some v -> v | None -> string_of_int n
+
+let month t n =
+ if n < 1 || n > 12 then string_of_int n
+ else match lookup t (fun x -> x.month) (string_of_int n) with Some v -> v | None -> string_of_int n
+
+let code t = t.code
+let fallback_code t = t.fallback_code
+
+let raw =
+ { code = "raw"; fallback_code = None; celebration = empty_table; weekday = empty_table;
+ month = empty_table; season = empty_table; rank = empty_table; colour = empty_table;
+ term = empty_table; chain = None }
+
+let with_fallback t base = { t with chain = Some base }
+
+let of_string text =
+ match OI.parse_sections text with
+ | Error e -> Error e
+ | Ok sections ->
+ (* Merge EVERY section sharing [name], not just the first: a hand-edited
+ 595-entry language file (Tasks 3/4's la.ini) WILL grow duplicate
+ [section] headers as contributors append entries over time -- a
+ second [celebration] block is the natural way to paste in a new
+ batch of names. Taking only the first match (the original
+ [List.find_opt] here) silently dropped every later block; the
+ failure then surfaces as a coverage report claiming those slugs have
+ "no Latin name", with nothing pointing back at the parser. Folding
+ over all matching sections, in file order, keeps this consistent
+ with the existing within-section behaviour below (last [SM.add]
+ wins): a key repeated across two blocks resolves to the later one,
+ exactly what a reader expects when appending to an INI file. *)
+ let find name =
+ List.fold_left
+ (fun m (s : OI.section) ->
+ if s.OI.name = name then List.fold_left (fun m (k, v) -> SM.add k v m) m s.OI.fields else m)
+ empty_table sections
+ in
+ let meta = find "meta" in
+ (match SM.find_opt "lang" meta with
+ | None -> Error "language file has no [meta] lang = <code>"
+ | Some code ->
+ Ok
+ { code;
+ fallback_code = SM.find_opt "fallback" meta;
+ celebration = find "celebration";
+ weekday = find "weekday";
+ month = find "month";
+ season = find "season";
+ rank = find "rank";
+ colour = find "colour";
+ term = find "term";
+ chain = None })
+
+let keys t =
+ let qualify prefix m = SM.bindings m |> List.map (fun (k, v) -> (prefix ^ "." ^ k, v)) in
+ List.concat
+ [ qualify "celebration" t.celebration; qualify "weekday" t.weekday;
+ qualify "month" t.month; qualify "season" t.season; qualify "rank" t.rank;
+ qualify "colour" t.colour; qualify "term" t.term ]
+ |> List.sort compare
diff --git a/lib/naming/lang.mli b/lib/naming/lang.mli
new file mode 100644
index 0000000..36931d4
--- /dev/null
+++ b/lib/naming/lang.mli
@@ -0,0 +1,60 @@
+(** A language table: strings to strings, nothing more.
+
+ Knows nothing about calendars, dates or rites, and never touches the
+ filesystem -- callers hand it text. That is what lets every command use it
+ without the kernel learning about presentation.
+
+ Every lookup is TOTAL. A key with no entry returns THE KEY ITSELF, never the
+ empty string: a partial translation must be usable from its first line, and
+ an untranslated day must still print something a reader can act on. This is
+ also why the pre-naming output (bare slugs) is exactly what an empty table
+ produces -- the degraded case is the old behaviour, not a blank page. *)
+
+type t
+
+(** Parse INI text. Never raises. [Error] on a malformed file or a missing
+ [\[meta\] lang].
+
+ Two duplicate policies, both LAST-WINS:
+ - A section name repeated in the file (e.g. two [\[celebration\]]
+ blocks) has ALL of its blocks merged, not only the first -- a
+ 595-entry hand-edited language file WILL grow duplicate section
+ headers as contributors append entries over time, and dropping a
+ later block would silently lose real translations.
+ - Where the same key appears more than once -- within one block or
+ across two of them -- the value from further down the file wins.
+
+ Both read the same order a reader would: later in the file overrides
+ earlier. This is the OPPOSITE direction from
+ {!Colitur_kernel.Overlay_ini.get} ([List.assoc_opt], first match) over
+ the very same [section.fields] shape -- the two modules resolve a
+ duplicate key in opposite directions, so do not assume one's behaviour
+ from the other's. *)
+val of_string : string -> (t, string) result
+
+val code : t -> string
+val fallback_code : t -> string option
+
+(** [with_fallback t base] resolves through [t] first, then [base], then the key. *)
+val with_fallback : t -> t -> t
+
+(** The identity table: every lookup returns its key. This is what [--raw] uses,
+ so raw output is one table passed around rather than a special case threaded
+ through every call site. *)
+val raw : t
+
+val celebration : t -> string -> string
+val season : t -> string -> string
+val rank : t -> string -> string
+val colour : t -> string -> string
+val term : t -> string -> string
+
+(** [weekday t n], 0 = Sunday. Out-of-range [n] returns [string_of_int n]. *)
+val weekday : t -> int -> string
+
+(** [month t n], 1 = January. Out-of-range [n] returns [string_of_int n]. *)
+val month : t -> int -> string
+
+(** Every (section-qualified key, value) pair, sorted. Used by [lang --dump] and
+ [lang --check]. Keys are qualified as e.g. ["celebration.ef-epiphany"]. *)
+val keys : t -> (string * string) list