From 45bcbde502e07ad73b96039fcbb62471a3c94781 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 13:34:53 +0200 Subject: feat(naming): the language table Maps strings to strings and nothing else -- no calendars, no dates, no filesystem. That is what lets every command use it without the kernel learning about presentation. Every lookup is total, and a miss returns THE KEY rather than the empty string. A partial translation is therefore usable from its first line, and the fully-degraded case is exactly today's output (bare slugs) rather than a blank page. --raw is a real identity table, not a special case threaded through every call site: one value the whole program passes around. Reuses Overlay_ini's INI reader rather than growing a second one that would drift in its comment, quoting and trimming rules; parse_sections is exposed in the .mli for that, with no behaviour change. Fixes one defect found while running the brief's own tests rather than transcribing them blind: weekday's internal lookup key is an English day-name word (month's is already the numeral string), so on a miss it echoed that word instead of the documented numeral, breaking both the 0=Sunday convention and Lang.raw's own identity contract for weekday. weekday/month now fall back to string_of_int n directly on a miss instead of through get's generic echo-the-search-key path; month is byte-identical since its key already equals string_of_int n. --- lib/naming/lang.ml | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 lib/naming/lang.ml (limited to 'lib/naming/lang.ml') diff --git a/lib/naming/lang.ml b/lib/naming/lang.ml new file mode 100644 index 0000000..0d902d5 --- /dev/null +++ b/lib/naming/lang.ml @@ -0,0 +1,96 @@ +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 -> + let find name = + match List.find_opt (fun (s : OI.section) -> s.OI.name = name) sections with + | Some s -> List.fold_left (fun m (k, v) -> SM.add k v m) empty_table s.OI.fields + | None -> empty_table + in + let meta = find "meta" in + (match SM.find_opt "lang" meta with + | None -> Error "language file has no [meta] lang = " + | 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 -- cgit v1.3 From 6bdd5afc5e185b639f9cf5e7c3d662ddc3ecf0e1 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 13:43:28 +0200 Subject: fix(naming): merge duplicate [section] blocks in the language table F1 (review round 1): of_string's find took only the FIRST section of a given name (List.find_opt), so a second [celebration] block anywhere in the file was silently dropped in its entirety -- reproduced with two blocks (a in the first, b in the second): b resolved to the slug fallback "b", not its real value. This is a data-loss footgun aimed squarely at what happens next: Tasks 3/4 write a 595-entry, hand-edited la.ini, and appending a second [celebration] block is the natural way to paste in a new batch of names. Worse, the failure surfaces nowhere near its cause -- a coverage check reports the dropped slugs as missing a Latin name, with nothing pointing at the parser. find now folds over every section sharing the name, in file order, so all blocks merge. This also settles which value wins when the same key appears in two different blocks: later in the file wins, consistent with the existing within-one-block behaviour (unchanged, still last SM.add wins) and with what a reader expects when appending to an INI file. lang.mli now documents both duplicate policies explicitly, and notes they run OPPOSITE to Overlay_ini.get's first-match (List.assoc_opt) over the same section.fields shape -- undocumented before, and a latent trap since the two modules read the same section type but resolve a duplicate key in opposite directions. Three tests added: two [celebration] blocks both resolve (the F1 regression), a key repeated across two blocks resolves to the later block, and a key repeated within one block still resolves to the later line (confirms unchanged behaviour). Confirmed the regression test fails against the pre-fix code (b resolves to "b", the slug fallback) and passes after. --- lib/naming/lang.ml | 19 ++++++++++++++++--- lib/naming/lang.mli | 18 +++++++++++++++++- test/test_lang.ml | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) (limited to 'lib/naming/lang.ml') diff --git a/lib/naming/lang.ml b/lib/naming/lang.ml index 0d902d5..147a49b 100644 --- a/lib/naming/lang.ml +++ b/lib/naming/lang.ml @@ -66,10 +66,23 @@ 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 = - match List.find_opt (fun (s : OI.section) -> s.OI.name = name) sections with - | Some s -> List.fold_left (fun m (k, v) -> SM.add k v m) empty_table s.OI.fields - | None -> empty_table + 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 diff --git a/lib/naming/lang.mli b/lib/naming/lang.mli index c4432f6..36931d4 100644 --- a/lib/naming/lang.mli +++ b/lib/naming/lang.mli @@ -13,7 +13,23 @@ type t (** Parse INI text. Never raises. [Error] on a malformed file or a missing - [\[meta\] lang]. *) + [\[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 diff --git a/test/test_lang.ml b/test/test_lang.ml index 0b16b30..b54e85b 100644 --- a/test/test_lang.ml +++ b/test/test_lang.ml @@ -74,6 +74,33 @@ let test_missing_meta_lang_is_error () = | Error _ -> () | Ok _ -> Alcotest.fail "a language file with no [meta] lang must be an Error" +(* F1 regression: a hand-edited language file WILL grow duplicate [section] + headers as contributors append entries over time (Tasks 3/4's 595-entry + la.ini). Both blocks' keys must resolve -- silently dropping the second + block is a data-loss footgun that surfaces as a false "missing name" + report far from its real cause. *) +let test_duplicate_sections_all_merge () = + let t = + ok + (L.of_string + "[meta]\nlang = la\n[celebration]\na = ALPHA\n[weekday]\nsunday = Dominica\n\ + [celebration]\nb = BETA\n") + in + Alcotest.(check string) "first block's key" "ALPHA" (L.celebration t "a"); + Alcotest.(check string) "second block's key" "BETA" (L.celebration t "b") + +let test_duplicate_key_across_sections_last_wins () = + let t = + ok + (L.of_string + "[meta]\nlang = la\n[celebration]\na = FIRST\n[celebration]\na = SECOND\n") + in + Alcotest.(check string) "later block's value wins" "SECOND" (L.celebration t "a") + +let test_duplicate_key_within_section_last_wins () = + let t = ok (L.of_string "[meta]\nlang = la\n[celebration]\na = FIRST\na = SECOND\n") in + Alcotest.(check string) "later line's value wins" "SECOND" (L.celebration t "a") + let suite = ( "Lang", [ Alcotest.test_case "meta" `Quick test_meta; @@ -82,4 +109,9 @@ let suite = Alcotest.test_case "fallback chain" `Quick test_fallback_chain; Alcotest.test_case "raw is identity" `Quick test_raw_is_identity; Alcotest.test_case "malformed is error" `Quick test_malformed_is_error_not_crash; - Alcotest.test_case "missing meta lang is error" `Quick test_missing_meta_lang_is_error ] ) + Alcotest.test_case "missing meta lang is error" `Quick test_missing_meta_lang_is_error; + Alcotest.test_case "duplicate sections all merge" `Quick test_duplicate_sections_all_merge; + Alcotest.test_case "duplicate key across sections: last wins" `Quick + test_duplicate_key_across_sections_last_wins; + Alcotest.test_case "duplicate key within section: last wins" `Quick + test_duplicate_key_within_section_last_wins ] ) -- cgit v1.3