diff --git a/docs/rfcs/001-codec.md b/docs/rfcs/001-codec.md new file mode 100644 index 0000000..307b750 --- /dev/null +++ b/docs/rfcs/001-codec.md @@ -0,0 +1,821 @@ +# RFC 001: Codec - Generic Type Serialization Library + +| Metadata | Value | +|--------------|---------------------------| +| Status | Draft | +| Created | 2025-12-19 | +| Authors | OCamlPro | + +## Summary + +This RFC proposes the inclusion of a `codec` library in `ocaml-sdk` that provides a +minimal, format-agnostic abstraction for encoding and decoding OCaml types. + +**Core principles:** + +1. **Lightweight**: Minimal dependencies (only `fmt` for error formatting), small + runtime footprint +2. **Format-agnostic**: The core library knows nothing about JSON, YAML, or any + specific format. It only provides the abstraction and combinators. +3. **Extensible**: Users can easily define their own drivers for custom formats +4. **Composable**: Streaming and other advanced patterns emerge naturally from + codec composition rather than special APIs + +The library consists of: + +1. A minimal runtime library defining the `Codec.t` type and combinators +2. A PPX deriver (`[@@deriving codec]`) for automatic codec generation +3. A driver interface that users implement for their target formats +4. Optional companion packages for standard formats (JSON, YAML, etc.) + +## Motivation + +### The Problem + +When building OCaml applications, developers frequently need to serialize and +deserialize data structures for various purposes: + +- Configuration files (JSON, YAML, TOML) +- Network protocols (JSON-RPC, binary protocols) +- Database storage +- Inter-process communication +- File formats + +Currently, each serialization format requires its own PPX or manual encoding: + +```ocaml +type config = { + host: string; + port: int; +} +[@@deriving yojson] (* For JSON *) +[@@deriving yaml] (* For YAML - separate PPX *) +[@@deriving sexp] (* For S-expressions - yet another PPX *) +``` + +This approach has several drawbacks: + +1. **Inconsistent APIs**: Different libraries have different conventions +2. **No format abstraction**: Code is coupled to a specific format +3. **Testing complexity**: Each format needs separate test coverage + +### The Solution + +A unified codec abstraction where one derives a single codec that works with +any format through a driver system: + +```ocaml +type config = { + host: string; + port: int; +} +[@@deriving codec ~driver:(module Json)] + +(* Later, switch to YAML with minimal changes *) +[@@deriving codec ~driver:(module Yaml)] +``` + +Or even better, derive format-agnostic codecs and choose the driver at runtime: + +```ocaml +(* Derive once *) +type config = { ... } +[@@deriving codec ~driver:(module Driver)] + +(* Use with any driver *) +let json_str = Codec.encode config_codec value |> Json.to_string +let yaml_str = Codec.encode config_codec value |> Yaml.to_string +``` + +## State of the Art + +### Existing OCaml Serialization Libraries + +#### 1. ppx_deriving_yojson + +[ppx_deriving_yojson](https://github.com/ocaml-ppx/ppx_deriving_yojson) generates +JSON codecs using the Yojson library. + +**Strengths:** +- Mature and widely used +- Good error messages with location information +- Supports records, variants, polymorphic variants +- Customizable field names via `[@key]` + +**Limitations:** +- JSON-only: cannot target other formats +- Generates two separate functions (`to_yojson`, `of_yojson`) rather than a + unified codec value +- No driver abstraction + +#### 2. ppx_protocol_conv + +[ppx_protocol_conv](https://github.com/andersfugmann/ppx_protocol_conv) is the +closest to our approach with its driver-based architecture. It was a major +inspiration for `codec`. + +**Strengths:** +- Driver-based: supports JSON (Yojson), YAML, MessagePack, XML +- Single annotation for multiple formats +- Customizable via `[@key]`, `[@default]`, `[@name]` +- Separate driver packages (`ppx_protocol_conv_json`, `ppx_protocol_conv_yaml`, etc.) + +**Limitations:** + +- **Binary size overhead**: In our experience, using `ppx_protocol_conv` resulted + in significantly larger binaries. The runtime library and generated code add + considerable weight to the final executable. This is a common issue with PPX + libraries that pull in heavy dependencies. + +- **Verbose API**: The generated functions follow the pattern: + ```ocaml + val record_to_json : record -> Json.t + val record_of_json_exn : Json.t -> record + val record_of_json : Json.t -> (record, error) result + ``` + This leads to three functions per type per driver, rather than a single + composable codec value. + +- **Driver coupling**: Each driver package (`ppx_protocol_conv_json`, etc.) + brings its own dependencies. The PPX itself is coupled to the driver + ecosystem rather than being truly format-agnostic. + +- **No first-class codec values**: Cannot pass codecs as values, compose them, + or store them in data structures. + +- **No codec combinators**: No built-in way to transform or compose codecs + (e.g., `Codec.map`, `Codec.compose`). + +- **Runtime structure**: The runtime uses a relatively heavy intermediate + representation that may not be optimal for all use cases. + +- **Does not support GADTs or extensible types** + +#### 3. data-encoding (Tezos) + +[data-encoding](https://octez.tezos.com/docs/developer/data_encoding.html) is +a GADT-based library used in Tezos for type-safe serialization. + +**Strengths:** +- Type-safe encoding with GADTs +- Supports both binary and JSON +- Rich combinator library +- Designed for security-critical applications + +**Limitations:** +- Encodings are written manually (no PPX) +- Verbose for simple types +- Tightly coupled to Tezos ecosystem +- Complex API with steep learning curve + +#### 4. Repr (Irmin/MirageOS) + +[Repr](https://mirage.github.io/repr/repr/Repr/index.html) provides runtime +type representations with serialization capabilities. + +**Strengths:** +- Rich type representation system +- Supports JSON and binary +- Efficient binary encoding with size optimization +- PPX available (`ppx_repr`) + +**Limitations:** +- Primarily designed for Irmin's needs +- Heavy dependency for simple use cases +- Less focus on human-readable formats + +#### 5. ppx_sexp_conv / ppx_bin_prot (Jane Street) + +Jane Street's serialization PPXs for S-expressions and binary protocols. + +**Strengths:** +- Battle-tested in production +- Excellent performance (bin_prot) +- Good integration with Core ecosystem + +**Limitations:** +- Tied to specific formats (sexp or binary) +- Core/Base ecosystem dependency +- No driver abstraction + +#### 6. ATD (Adaptable Type Definitions) + +[ATD](https://github.com/ahrefs/atd) takes a fundamentally different approach: +types are defined in a separate `.atd` IDL file, and a code generator produces +OCaml types along with serialization functions. + +``` +(* user.atd *) +type user = { + name: string; + age: int; +} +``` + +```sh +$ atdgen -t user.atd # generates user_t.ml(i) (types) +$ atdgen -j user.atd # generates user_j.ml(i) (JSON serializers) +``` + +**Strengths:** +- Cross-language code generation from a single source (OCaml, TypeScript, + Python, Java, Scala, C++, D) +- Generated code is readable and can be reviewed/versioned +- Protocol compatibility checking via `atddiff` +- Efficient binary format (Biniou) in addition to JSON +- Well-suited for API contracts between services +- Production-tested at Ahrefs + +**Limitations:** +- Types live in separate `.atd` files, not inline OCaml +- Limited type expressivity (no GADTs, no OCaml-specific features) to maintain + cross-language compatibility +- Extra build step (code generation before compilation) +- No first-class codec values or composition +- Generated functions are format-specific, not driver-abstract + +**Positioning with codec:** + +ATD and codec are complementary rather than competing: + +- **ATD** excels at defining shared data contracts between services in different + languages. When the problem is "I need the same types in OCaml, TypeScript, + and Python", ATD is the right tool. + +- **Codec** excels at serializing OCaml-native types with full type system + expressivity (GADTs, polymorphic variants, etc.), first-class composition, + and custom driver support. When the problem is "I need to serialize complex + OCaml types to various formats", codec is the right tool. + +In practice, both could coexist in the SDK: ATD for externally-facing API types +shared across language boundaries, and codec for internal OCaml types that +need flexible serialization. + +### API Comparison: ppx_protocol_conv vs codec + +To illustrate the difference in ergonomics, here's how the same task looks +with both libraries: + +**ppx_protocol_conv:** +```ocaml +open Protocol_conv_json + +type user = { name: string; age: int } +[@@deriving protocol ~driver:(module Json)] + +(* Generated: 3 functions *) +val user_to_json : user -> Json.t +val user_of_json : Json.t -> (user, error) result +val user_of_json_exn : Json.t -> user + +(* Usage: functions, not values *) +let json = user_to_json { name = "Alice"; age = 30 } +let user = user_of_json_exn json + +(* Cannot easily compose or pass around *) +let serialize_list users = List.map user_to_json users +``` + +**codec (proposed):** +```ocaml +open Codec + +type user = { name: string; age: int } +[@@deriving codec ~driver:(module Json)] + +(* Generated: 1 codec value *) +val user_codec : (user, Json.t) Codec.t + +(* Usage: first-class value *) +let json = Codec.encode user_codec { name = "Alice"; age = 30 } +let user = Codec.decode user_codec json + +(* Compose naturally: build a codec for user list -> JSON array *) +let users_codec : (user list, Json.t) Codec.t = + Codec.list Json.list user_codec +let serialize_list users = Codec.encode users_codec users + +(* Pass as argument *) +let save_to_file : ('a, Json.t) Codec.t -> 'a -> string -> unit = + fun codec value path -> + let json_str = Codec.encode codec value |> Json.to_string in + Out_channel.with_open_text path (fun oc -> Out_channel.output_string oc json_str) +``` + +### Comparison Table + +| Feature | yojson | protocol_conv | data-encoding | repr | ATD | codec (ours) | +|----------------------------|--------|---------------|---------------|------|--------|--------------| +| Driver-based | No | Yes | Partial | No | No | Yes | +| First-class codec values | No | No | Yes | Yes | No | Yes | +| PPX / inline types | Yes | Yes | No | Yes | No | Yes | +| Codec combinators | No | No | Yes | Yes | No | Yes | +| Runtime driver selection | N/A | No | N/A | No | N/A | Yes | +| Lightweight core | Yojson | Heavy | Heavy | repr | Low | fmt only | +| Binary size impact | Medium | High | High | High | Low | Low | +| Streaming (via composition)| No | No | Partial | No | No | Yes | +| Easy custom drivers | N/A | Medium | Hard | Hard | N/A | Yes | +| Ergonomic API | Medium | Low | Low | Medium | High | High | +| Custom field names | Yes | Yes | Manual | Yes | Yes | Yes | +| Default values | Yes | Yes | Manual | No | Yes | Yes | +| Recursive types | Yes | Yes | Yes | Yes | Yes | Yes | +| Parametric types | Yes | Yes | Yes | Yes | Yes | Yes | +| Cross-language support | No | No | No | No | Yes | No | +| Full OCaml type system | Yes | Partial | Yes | Yes | No | Yes | + +### Why a New Library? + +None of the existing solutions fully satisfies our requirements: + +1. **Truly lightweight**: Most libraries pull in heavy dependencies. We want a + core that only depends on `fmt` for error formatting. No Yojson, no Core, no + large framework. + +2. **Format-agnostic by design**: The core library should not mention JSON, YAML, + or any specific format. It provides the abstraction; users bring their own + formats. This is different from `ppx_protocol_conv` which, while driver-based, + still couples the PPX to specific driver packages. + +3. **First-class codec values**: We want `('a, 'driver) Codec.t` as a value that + can be passed around, composed, and stored, not just generated functions. + +4. **Easy custom drivers**: Implementing a driver for a custom format (proprietary + binary protocol, database rows, etc.) should be straightforward and well-documented. + +5. **Composability**: Codecs should compose naturally with combinators like + `option`, `list`, `compose`. + +6. **Bidirectionality**: A single codec value handles both encoding and decoding, + ensuring they stay in sync. + +7. **Streaming via composition**: For large data, streaming emerges naturally + by composing codecs with lazy types (`Seq.t`) rather than requiring special APIs. + +## Current Implementation + +The existing `ocamlpro-codec` library provides a foundation with: + +### Core Types + +```ocaml +type ('a, 'b) t (* A codec from 'a to 'b *) + +val make : encode:('a -> 'b) -> decode:('b -> 'a) -> ('a, 'b) t +val encode : ('a, 'b) t -> 'a -> 'b +val decode : ('a, 'b) t -> 'b -> 'a (* Currently raises on error, see Proposed Improvements *) +``` + +### Combinators + +```ocaml +val identity : unit -> ('a, 'a) t +val swap : ('a, 'b) t -> ('b, 'a) t +val compose : ('a, 'b) t -> ('b, 'c) t -> ('a, 'c) t +val option : ('b option, 'b) t -> ('a, 'b) t -> ('a option, 'b) t +val list : ('b list, 'b) t -> ('a, 'b) t -> ('a list, 'b) t +val array : ('b array, 'b) t -> ('a, 'b) t -> ('a array, 'b) t +``` + +### Driver Protocol + +```ocaml +module type DRIVER = sig + type t + val unit : (unit, t) codec + val bool : (bool, t) codec + val int : (int, t) codec + val float : (float, t) codec + val char : (char, t) codec + val string : (string, t) codec + val option : (t option, t) codec + val list : (t list, t) codec + val array : (t array, t) codec + val tuple : (t list, t) codec + val dict : ((string * t) list, t) codec +end +``` + +### PPX Deriver + +```ocaml +type color = + | Red + | Green + | Blue [@name "Bleu"] + | Custom of int * int * int + | RGB of { r: int; g: int; b: int } +[@@deriving codec ~driver:(module Json)] + +(* Generates: val color_codec : (color, Json.t) Codec.t *) +``` + +**Supported attributes:** +- `[@name "..."]` - Rename field or constructor in serialized form +- `[@default expr]` - Default value for missing fields +- `~omit_defaults` - Omit fields equal to their default value +- `~suffix "..."` - Add suffix to generated codec name + +## Proposed Architecture + +### Package Structure + +The library should be split into minimal, focused packages: + +``` +codec/ # Core library (no format dependencies) +├── codec.opam # Only depends on: fmt +├── src/ +│ ├── codec.ml # Core types and combinators +│ └── codec.mli +│ +ppx_codec/ # PPX deriver (separate package) +├── ppx_codec.opam # Depends on: codec, ppxlib +└── src/ + ├── ppx_codec.ml # Deriver registration and entry point + ├── gen_encode.ml # Encoder generation logic + ├── gen_decode.ml # Decoder generation logic + └── utils.ml # Shared helpers (attribute parsing, etc.) +│ +codec-json/ # Optional: JSON driver (separate package) +├── codec-json.opam # Depends on: codec, yojson +└── src/ + └── codec_json.ml +│ +codec-yaml/ # Optional: YAML driver (separate package) +└── ... +``` + +Users only install what they need. The core `codec` package has **zero format +dependencies**. + +### Driver Interface + +The driver interface should be minimal and easy to implement: + +```ocaml +module type DRIVER = sig + (** The target type of serialization (e.g., Yojson.t, bytes, etc.) *) + type t + + (** Primitive codecs *) + val unit : (unit, t) Codec.t + val bool : (bool, t) Codec.t + val int : (int, t) Codec.t + val int32 : (int32, t) Codec.t + val int64 : (int64, t) Codec.t + val float : (float, t) Codec.t + val char : (char, t) Codec.t + val string : (string, t) Codec.t + + (** Structural codecs *) + val option : (t option, t) Codec.t + val list : (t list, t) Codec.t + val array : (t array, t) Codec.t + val tuple : (t list, t) Codec.t + val dict : ((string * t) list, t) Codec.t +end +``` + +A custom driver for, say, a proprietary binary format would look like +(simplified, without error handling): + +```ocaml +module My_binary_driver : Codec.DRIVER = struct + type t = bytes + + let int = Codec.make + ~encode:(fun i -> + let b = Bytes.create 4 in + Bytes.set_int32_be b 0 (Int32.of_int i); + b) + ~decode:(fun b -> + Bytes.get_int32_be b 0 |> Int32.to_int) + + (* ... other primitives ... *) +end +``` + +### Streaming via Composition + +Streaming is not a special API - it's naturally handled through codec composition. +The key insight is that codecs compose, so streaming is just another codec in the chain. + +For example, to stream XML using Daniel Bünzli's Xmlm library: + +```ocaml +(* Xmlm provides a streaming signal type *) +type xml_signal = [ `El_start of ... | `El_end | `Data of string ] + +(* A codec between our domain type and a sequence of XML signals *) +let foo_xml_signals : (foo, xml_signal Seq.t) Codec.t = ... + +(* Compose with I/O: the Seq.t is consumed/produced lazily *) +let write_foo : foo -> out_channel -> unit = fun value oc -> + let signals = Codec.encode foo_xml_signals value in + let output = Xmlm.make_output (`Channel oc) in + Seq.iter (Xmlm.output output) signals (* lazy: no buffering *) + +let read_foo : in_channel -> foo = fun ic -> + let input = Xmlm.make_input (`Channel ic) in + let signals = Seq.of_dispenser (fun () -> + match Xmlm.input input with + | signal -> Some signal + | exception End_of_file -> None + ) in + Codec.decode foo_xml_signals signals (* lazy: processes as it reads *) +``` + +This approach has several advantages: + +1. **No API bloat**: The core `Codec.t` type stays simple +2. **True streaming**: Using `Seq.t` or similar lazy types avoids buffering +3. **Flexibility**: Users choose their streaming strategy +4. **Composition**: Chain codecs naturally (`foo <-> xml_signals Seq.t <-> channel`) +5. **Library agnostic**: Works with any streaming library (Xmlm, Jsonm, Angstrom, etc.) + +The same pattern works for JSON with Jsonm, binary with Angstrom/Faraday, etc. + +## Proposed Improvements + +### 1. Better Error Handling + +**Current state:** Errors use `failwith` or a simple `Error` exception. + +**Proposed:** +```ocaml +type error = { + path: string list; (* e.g., ["config"; "server"; "port"] *) + expected: string; (* e.g., "int" *) + got: string; (* e.g., "string \"abc\"" *) + message: string option; (* Additional context *) +} + +type 'a result = ('a, error) Result.t + +val decode : ('a, 'b) t -> 'b -> 'a result +val decode_exn : ('a, 'b) t -> 'b -> 'a (* For convenience *) +``` + +Error messages should include the path to the failing field: +``` +Error decoding config.server.port: expected int, got string "abc" +``` + +### 2. Improved PPX Diagnostics + +**Current state:** PPX errors can be cryptic. + +**Proposed:** +- Clear error messages for unsupported type constructs +- Suggestions for fixing common mistakes +- Location-accurate error reporting + +### 3. Additional Combinators + +```ocaml +(* Result type support *) +val result : ('ok, 'b) t -> ('err, 'b) t -> (('ok, 'err) result, 'b) t + +(* Map over codecs *) +val map : ('a -> 'b) -> ('b -> 'a) -> ('a, 'c) t -> ('b, 'c) t + +(* Lazy codecs for recursive types *) +val lazy_ : ('a, 'b) t Lazy.t -> ('a, 'b) t + +(* Validation *) +val validate : ('a -> bool) -> string -> ('a, 'b) t -> ('a, 'b) t +``` + +### 4. Documentation + +- Comprehensive API documentation with examples +- Tutorial for common use cases +- Guide for implementing custom drivers + +### 5. Test Suite + +- Unit tests for all combinators +- Property-based tests for encode/decode roundtrips +- PPX output tests for generated code +- Example drivers with integration tests + +### 6. Example Drivers (Separate Packages) + +Provide example drivers as **separate optional packages**: + +- `codec-json` - JSON driver using Yojson (example, not required) +- `codec-yaml` - YAML driver using ocaml-yaml (example, not required) + +These serve as: +1. Ready-to-use drivers for common formats +2. Reference implementations for custom driver authors +3. Test fixtures for the core library + +**Important**: The core `codec` library does NOT depend on these. They are +purely optional. + +## Use Cases + +### 1. Simple In-Memory Format + +A developer wants to serialize OCaml values to a simple tagged format for +debugging or logging: + +```ocaml +(* Define a trivial driver in a few dozen lines *) +module Debug_driver : Codec.DRIVER = struct + type t = string + + let unit = Codec.make ~encode:(fun () -> "()") ~decode:(fun _ -> ()) + let bool = Codec.make ~encode:string_of_bool ~decode:bool_of_string + let int = Codec.make ~encode:string_of_int ~decode:int_of_string + (* ... *) +end + +(* Use it *) +type point = { x: int; y: int } +[@@deriving codec ~driver:(module Debug_driver)] + +let () = print_endline (Codec.encode point_codec { x = 1; y = 2 }) +(* Output: {x=1, y=2} *) +``` + +### 2. Custom Binary Protocol + +A game developer needs to serialize game state over the network: + +```ocaml +module Game_protocol : Codec.DRIVER = struct + type t = Bytes.t + (* Compact binary encoding optimized for network *) + let int = Codec.make + ~encode:(fun i -> (* varint encoding *) ...) + ~decode:(fun b -> (* varint decoding *) ...) + (* ... *) +end +``` + +### 3. Database Row Mapping + +Map OCaml records to database rows: + +```ocaml +module Postgres_driver : Codec.DRIVER = struct + type t = string array (* Row as array of strings *) + (* ... *) +end + +type user = { id: int; name: string; email: string } +[@@deriving codec ~driver:(module Postgres_driver)] +``` + +### 4. Configuration with Multiple Formats + +Support both JSON and YAML config files with the same types: + +```ocaml +type config = { host: string; port: int } +[@@deriving codec ~driver:(module Json_driver)] +[@@deriving codec ~driver:(module Yaml_driver) ~suffix:"yaml"] + +(* Use either *) +let config = Codec.decode config_codec json_data +let config = Codec.decode config_yaml_codec yaml_data +``` + +## Alternatives Considered + +### Runtime Type Representation Approach + +An alternative architecture would be to generate a *runtime representation* of +types rather than generating codec code directly. This approach is used by +libraries like Jane Street's `typerep` and MirageOS's `Repr`. + +**How it works:** + +```ocaml +(* Instead of generating codec code, generate a type representation *) +type user = { name: string; age: int } +[@@deriving typerep] + +(* Generated: a value describing the type structure *) +val typerep_of_user : user Typerep.t + +(* Then, generic functions interpret this representation *) +let json = Generic_json.encode typerep_of_user { name = "Alice"; age = 30 } +let yaml = Generic_yaml.encode typerep_of_user { name = "Alice"; age = 30 } +let schema = Generic_schema.generate typerep_of_user +``` + +**Advantages:** + +1. **Maximum reusability**: Derive once, use for any operation (serialization, + pretty-printing, comparison, schema generation, validation, diffing, etc.) + +2. **Decoupled evolution**: New interpreters can be added without modifying + type definitions or re-running the PPX + +3. **Smaller generated code**: Only the type structure is generated, not + format-specific code for each driver + +4. **Runtime flexibility**: The same representation works with any interpreter, + chosen at runtime + +**Trade-offs:** + +1. **Runtime overhead**: Each operation must interpret the type structure at + runtime. However, this overhead is typically negligible: + - Passing an extra argument (the type representation) is cheap + - Pattern matching on type structure compiles to efficient jump tables + - Real serialization work (string allocation, I/O) dominates the cost + - Only problematic for tight loops on millions of small values + +2. **API complexity**: Users must understand the type representation abstraction, + not just encode/decode functions + +3. **Less compile-time optimization**: The compiler cannot inline format-specific + code, though this rarely matters in practice + +### Use Existing Library vs. Define Our Own? + +If we pursue the runtime type representation approach, we must decide whether +to reuse an existing library or define our own: + +#### Option A: Use `typerep` (Jane Street) + +**Pros:** +- Mature, production-tested +- Rich generic programming capabilities +- Good for type equality proofs + +**Cons:** +- Designed for Jane Street's ecosystem, may not fit our needs perfectly +- Adds dependency on Jane Street libraries +- No built-in serialization (we'd still need to write interpreters) + +#### Option B: Use `Repr` (MirageOS/Irmin) + +**Pros:** +- Battle-tested in Irmin (distributed database) +- Includes efficient binary and JSON serialization +- Good performance characteristics + +**Cons:** +- Heavy dependency, designed for MirageOS/Irmin needs +- May include features we don't need +- Less control over the representation design + +#### Option C: Define Our Own Lightweight Representation + +**Pros:** +- Minimal dependencies (aligned with codec's philosophy) +- Tailored to our exact needs +- Full control over design decisions +- Can be kept simple and focused + +**Cons:** +- Development and maintenance cost +- Yet another type representation in the ecosystem +- Must prove correctness and performance ourselves + +## Open Questions + +1. **Naming**: Should the library be called `codec`, `encoding`, `serial`, or + something else? + +2. **Error handling**: Should we use `Result.t` everywhere or provide both + exception and result-based APIs? + +3. **Dependency on `fmt`**: Currently we depend on `fmt` for error formatting. + Should we remove this dependency entirely and use `Format` from stdlib? + This would make the library truly zero-dependency. + +4. **Driver at compile-time vs runtime**: The current PPX requires specifying + the driver at compile time. Should we support runtime driver selection? + This would require a more complex type using rank-2 polymorphism: + ```ocaml + type 'a codec = { encode: 'driver. 'a -> 'driver; ... } + ``` + +5. **Attribute syntax**: Should we use `[@codec.name]` namespace or keep the + shorter `[@name]`? + +6. **Backward compatibility**: How do we handle protocol evolution and versioning? + +## Migration Path + +For users of `ocamlpro-stdlib`: + +1. The library will be moved to `ocaml-sdk` as `codec` +2. PPX will be renamed from `ocamlpro-ppx` to `ppx_codec` or similar +3. Module names will change: `Ocamlpro_codec.Codec` -> `Codec` + +## References + +- [ppx_deriving_yojson](https://github.com/ocaml-ppx/ppx_deriving_yojson) +- [ppx_protocol_conv](https://github.com/andersfugmann/ppx_protocol_conv) +- [data-encoding (Tezos)](https://octez.tezos.com/docs/developer/data_encoding.html) +- [Repr (MirageOS)](https://mirage.github.io/repr/repr/Repr/index.html) +- [ppx_sexp_conv](https://opam.ocaml.org/packages/ppx_sexp_conv/) +- [ATD (Adaptable Type Definitions)](https://github.com/ahrefs/atd) +- [Real World OCaml - Data Serialization](https://dev.realworldocaml.org/data-serialization.html) diff --git a/docs/rfcs/002-i18n.md b/docs/rfcs/002-i18n.md new file mode 100644 index 0000000..ba25c72 --- /dev/null +++ b/docs/rfcs/002-i18n.md @@ -0,0 +1,1096 @@ +# RFC 002: Internationalisation - Support i18n idiomatique pour OCaml + +| Metadata | Value | +|--------------|---------------------------| +| Status | Draft | +| Created | 2026-04-10 | +| Authors | OCamlPro | + +## Summary + +Cette RFC propose une solution d'internationalisation (i18n) complète pour OCaml, +visant un niveau de support équivalent à Java (`java.text`, ICU4J) et +JavaScript (API `Intl`, FormatJS). La solution se compose de trois briques +complémentaires : + +1. **`ocaml-icu4x`** : une bibliothèque de bindings OCaml vers ICU4X (Unicode + Consortium) fournissant le formatage locale-aware (nombres, dates, monnaies), + la collation, les règles de pluriel et la segmentation de texte +2. **`ppx_i18n`** : un PPX offrant une ergonomie développeur avec des garanties + compile-time (extraction statique, interpolation typée, vérification de + complétude des traductions) +3. **`ocaml-i18n-tool`** : un outil CLI pour le workflow de traduction + (extraction, validation, conversion de catalogues, statistiques de couverture) + +**Principes directeurs :** + +1. **Type-safe** : les erreurs d'interpolation sont détectées à la compilation, + pas au runtime --- c'est l'avantage distinctif d'OCaml sur Java/JS +2. **Standards** : s'appuyer sur les standards existants (CLDR, Unicode + MessageFormat 2.0, PO/XLIFF) plutôt que réinventer +3. **Incrémental** : chaque brique est utilisable indépendamment et apporte de + la valeur seule +4. **Léger** : pas de dépendance système lourde grâce à ICU4X (données CLDR + embarquées, pas besoin d'ICU4C installé) + +## Motivation + +### Le problème + +L'écosystème OCaml souffre de lacunes significatives en matière +d'internationalisation. Un développeur souhaitant produire une application +multilingue se heurte à plusieurs obstacles : + +**Absence de formatage locale-aware.** Il n'existe aucune bibliothèque OCaml +maintenue pour formater des nombres, dates ou monnaies selon les conventions +locales. En Java, `NumberFormat.getInstance(Locale.FRANCE).format(1234.56)` +produit `"1 234,56"`. En OCaml, il faut coder cela manuellement ou passer par +un appel FFI ad hoc. + +**Outillage de traduction limité.** `ocaml-gettext` fournit un support GNU +gettext fonctionnel, mais sans vérification de type sur les interpolations, sans +détection des traductions manquantes à la compilation, et avec une API qui n'a +pas évolué depuis plusieurs années. + +**Pas d'accès aux données CLDR.** Les règles de pluriel, les formats de date par +locale, les noms de monnaies --- toutes ces données maintenues par le Unicode +Consortium via CLDR --- ne sont pas accessibles depuis OCaml. + +**Fragmentation.** Le traitement Unicode (bibliothèques de Daniel Bünzli), la +traduction (`ocaml-gettext` + Camomile) et le formatage (inexistant) sont +découplés sans intégration cohérente. + +### La solution + +Une pile i18n cohérente en trois couches, chacune adressant un temps différent +du cycle de développement : + +| Couche | Temps | Composant | Rôle | +|--------|-------|-----------|------| +| Runtime | Exécution | `ocaml-icu4x` | Formatage, collation, pluriel, données CLDR | +| PPX | Compilation | `ppx_i18n` | Ergonomie, type-safety, extraction | +| Outil | CI / Workflow | `ocaml-i18n-tool` | Gestion des catalogues de traduction | + +## État de l'art + +### i18n dans l'écosystème OCaml actuel + +#### Bibliothèques Unicode de Daniel Bünzli + +La suite de bibliothèques de Daniel Bünzli constitue le socle Unicode de +l'écosystème OCaml, maintenu à jour avec Unicode 17.0.0 (septembre 2025) : + +| Package | Rôle | +|---------|------| +| **uutf** (1.0.4) | Codec streaming UTF-8/UTF-16 | +| **uucp** (17.0.0) | Propriétés de caractères Unicode (catégories, scripts, case mappings, break properties) | +| **uunf** (17.0.0) | Normalisation de texte (NFC, NFD, NFKC, NFKD) | +| **uuseg** (17.0.0) | Segmentation de texte (grapheme clusters, mots, phrases, line breaks) | +| **uucd** (17.0.0) | Décodeur de la base de données de caractères Unicode | + +Ces bibliothèques sont modulaires, légères et de qualité. Elles couvrent le +traitement de texte Unicode de manière satisfaisante mais ne fournissent aucune +fonctionnalité i18n (pas de locale, pas de formatage, pas de traduction). + +#### Camomile + +Bibliothèque monolithique plus ancienne fournissant : types de chaînes +UTF-8/16/32, conversion entre ~200 encodages, collation et case mapping +locale-sensitive, et formes normales Unicode. Moins activement développée que la +suite Bünzli, mais toujours utilisée comme backend par `ocaml-gettext`. + +#### ocaml-gettext + +Version 0.5.0 (mars 2025). Fournit un support GNU gettext compatible : + +- Fonctions `s_`, `f_`, `sn_`, `fn_` pour marquer les chaînes traduisibles +- Outil d'extraction de chaînes depuis les sources OCaml (génère des fichiers PO) +- Backend Camomile (pur OCaml) ou stub C (GNU gettext système) + +**Limites :** +- Pas de vérification de type sur les interpolations (`Printf`-style) +- Pas de détection des traductions manquantes à la compilation +- Pas de support MF2 (pas de sélecteurs genre/pluriel riches, pas de localisation asymétrique) +- Dépendance sur Camomile (lourd pour le backend pur OCaml) + +#### sedlex + +Générateur de lexer Unicode-aware (successeur d'ulex). Travaille sur des flux de +codepoints Unicode plutôt que des octets. Utile pour les parsers manipulant de +l'entrée Unicode, mais sans rapport direct avec l'i18n. + +#### Lacunes identifiées + +| Domaine | Situation actuelle | +|---------|-------------------| +| Bindings ICU | Aucun maintenu | +| Formatage locale-aware (nombres, dates, monnaies) | Inexistant | +| Données CLDR | Aucune bibliothèque OCaml | +| Règles de pluriel CLDR | Seulement `ngettext` (2 formes) | +| MessageFormat 2.0 (MF2) | Aucun équivalent | +| Collation CLDR | Seulement Camomile (données anciennes) | + +### i18n dans les langages mainstream + +#### Java / Kotlin --- La référence + +- **Unicode** : chaînes UTF-16, support complet incluant les supplementary + characters +- **Stdlib** : le support i18n le plus riche de tous les langages. `java.util.Locale`, + `java.text.MessageFormat` (ICU MessageFormat), `NumberFormat`, `DateFormat`, + `Collator`, `ResourceBundle` pour les catalogues, `BreakIterator` pour la + segmentation. Depuis JDK 9, CLDR est la source de données locale par défaut. +- **Écosystème** : ICU4J est le standard de référence --- superset du JDK i18n. + Workflows de traduction matures (fichiers `.properties`, outillage XLIFF). +- **Forces** : couverture la plus complète out-of-the-box. Éprouvé à très + grande échelle. +- **Faiblesses** : syntaxe `MessageFormat` complexe. UTF-16 cause des pièges + occasionnels avec les surrogate pairs. + +#### JavaScript / TypeScript --- Le meilleur pour le web + +- **Unicode** : chaînes UTF-16. ES6 a ajouté l'itération par code points, + les escapes `\u{...}`, et le flag regex `u`. +- **Stdlib** : l'API `Intl` est puissante et native au moteur : + `Intl.NumberFormat`, `Intl.DateTimeFormat`, `Intl.Collator`, + `Intl.PluralRules`, `Intl.ListFormat`, `Intl.DisplayNames`, + `Intl.Segmenter`. Tout est basé sur CLDR. +- **Écosystème** : FormatJS (react-intl) et i18next dominent. ICU MessageFormat + largement adopté. Excellent outillage d'extraction et d'intégration CI. +- **Forces** : `Intl` est l'une des meilleures API i18n natives de tous les + langages. Écosystème le plus mature pour les workflows de traduction. +- **Faiblesses** : résultats `Intl` varient légèrement entre moteurs. + +#### Python --- Solide via l'écosystème + +- **Unicode** : chaînes Unicode natives (Python 3), représentation interne + flexible (Latin-1/UCS-2/UCS-4). +- **Stdlib** : module `gettext` (compatible GNU gettext), module `locale` pour + les paramètres système. Pas de collation au-delà de `strcoll`. +- **Écosystème** : Babel (basé sur CLDR) est le standard de facto --- formatage + locale-aware pour dates, nombres, monnaies, plus règles de pluriel et + extraction de messages. pyICU wrape ICU4C. Django/Flask ont des frameworks + i18n robustes. +- **Forces** : Babel est excellent. Intégration gettext mature. +- **Faiblesses** : `locale` stdlib est process-global et dépendant de la + plateforme. + +#### Go --- Solide via les sous-dépôts officiels + +- **Unicode** : chaînes sont des slices d'octets, code source UTF-8. Le type + `rune` représente un code point Unicode. +- **Stdlib** : le sous-dépôt `golang.org/x/text` (quasi-stdlib) fournit + `language`, `message`, `number`, `currency`, `collate` et `cases`. Le package + `message` supporte le pluriel/select style ICU MessageFormat. +- **Écosystème** : raisonnablement mature via `x/text`. `gotext` pour + l'extraction de traductions. +- **Forces** : `x/text` est complet et officiellement maintenu. Bon support des + règles de pluriel (basé sur CLDR). +- **Faiblesses** : `x/text` est techniquement "expérimental". + +#### Rust --- En progression rapide grâce à ICU4X + +- **Unicode** : chaînes (`str`/`String`) sont du UTF-8 garanti valide. +- **Stdlib** : minimal (stdlib volontairement petite). Pas de locale, collation + ou formatage de messages. +- **Écosystème** : ICU4X (écrit en Rust) est natif et fournit formatage + locale-aware, collation, pluriel, segmentation. `fluent-rs` (Project Fluent + par Mozilla) est la solution de messages la plus idiomatique. `rust-i18n` + fournit une approche macro gettext-like. +- **Forces** : ICU4X natif Rust = zero-copy, no-std capable, WASM-friendly. + Fluent est un format de messages moderne. +- **Faiblesses** : pas de story stdlib ; l'utilisateur doit assembler les pièces. + +#### C# / .NET --- Proche de Java + +- **Unicode** : chaînes UTF-16. `System.Text.Rune` depuis .NET Core 3.0. +- **Stdlib** : `System.Globalization` est complet : `CultureInfo`, + `NumberFormatInfo`, `DateTimeFormatInfo`, `CompareInfo` (collation). Fichiers + de ressources (`.resx`) avec outillage Visual Studio. Depuis .NET 5, ICU est + le backend de globalisation par défaut. +- **Forces** : intégration framework profonde. Backend ICU depuis .NET 5. +- **Faiblesses** : format `.resx` verbeux (XML). Règles de pluriel nécessitent + des bibliothèques tierces. + +#### Haskell --- Le pair fonctionnel le plus proche + +- **Unicode** : `Text` (package `text`) est UTF-16 en interne. +- **Stdlib** : aucun support i18n dans `base`. +- **Écosystème** : `haskell-gettext` peu maintenu. `text-icu` wrape ICU4C. + Pas de solution établie pour catalogues de messages ou règles de pluriel. +- **Forces** : le typage pourrait théoriquement permettre des traductions + vérifiées à la compilation. +- **Faiblesses** : le support i18n le plus faible de tous les langages étudiés. + Un avertissement pour OCaml : même profil (langage fonctionnel, petite + stdlib), même lacune. + +### Tableau comparatif de synthèse + +| Langage | Strings Unicode | i18n stdlib | Écosystème i18n | Évaluation | +|---------|----------------|-------------|-----------------|------------| +| Java/Kotlin | UTF-16, complet | Excellent | Excellent (ICU4J) | Référence | +| C#/.NET | UTF-16, backend ICU | Excellent | Très bon | Quasi-Java | +| JavaScript/TS | UTF-16, API Intl | Très bon | Excellent (FormatJS) | Meilleur pour le web | +| Python | Unicode natif | Basique | Très bon (Babel) | Solide | +| Go | UTF-8, rune | Bon (x/text) | Bon | Solide | +| Rust | UTF-8 garanti | Minimal | Bon (ICU4X natif) | En progression | +| Haskell | UTF-16 (Text) | Aucun | Faible | Insuffisant | +| **OCaml** | Octets + codecs UTF-8 | Aucun | Partiel (Unicode OK, i18n lacunaire) | **Insuffisant** | + +### ICU4X : la brique manquante + +ICU4X est un projet du Unicode Consortium, écrit en Rust, conçu comme le +successeur portable d'ICU4C/ICU4J. Caractéristiques clés : + +- **Couverture** : formatage locale-sensitive (nombres, dates, monnaies), + collation, règles de pluriel, segmentation, normalisation, systèmes de + calendrier +- **Conçu pour le FFI** : génère des bindings C, C++, JS/WASM et Dart via + l'outil `diplomat`. L'API C est stable et documentée. +- **Données embarquées** : CLDR compilé en blobs statiques via zero-copy + deserialization --- pas besoin d'ICU installé sur le système +- **Adoption** : utilisé dans Firefox et Android. Version 1.x stable. +- **Pertinence pour OCaml** : l'API C générée par `diplomat` se prête + naturellement à des bindings OCaml via `ctypes` ou stubs C manuels + +ICU4X est la voie la plus réaliste pour amener les données CLDR et le formatage +locale-aware dans l'écosystème OCaml sans maintenir manuellement une +implémentation de ces standards complexes. + +### MessageFormat 2.0 : le format de messages d'avenir + +Unicode MessageFormat 2.0 (MF2) est le nouveau standard de format de messages +publié dans LDML 46 (fin 2024) par le Unicode Consortium. Il succède à ICU +MessageFormat 1.0 et intègre les meilleures idées de Fluent (Mozilla). + +**Ce que MF2 apporte par rapport à MF1 :** + +| Aspect | ICU MessageFormat 1.0 | MessageFormat 2.0 | +|--------|----------------------|-------------------| +| Variables | Positionnelles (`{0}`) | Nommées (`{$count}`) | +| Sélecteurs | Imbriqués dans le message | Top-level (`.match`) | +| Localisation | Symétrique (même structure partout) | **Asymétrique** (chaque locale peut restructurer librement) | +| Extensibilité | Figée | Fonctions custom (`:number`, `:datetime`, etc.) | +| Syntaxe | `{0, plural, one {# item} other {# items}}` | `.match {$count :number}` + branches | + +**Ce que MF2 emprunte à Fluent :** +- La localisation asymétrique : chaque locale définit ses propres branches + de pluriel/select sans être contrainte par la langue source +- Les variables nommées (`$count` au lieu de `{0}`) +- La séparation entre logique de sélection et texte du message + +**Ce que MF2 a en plus de Fluent :** +- Un standard Unicode officiel (pas seulement Mozilla) +- Une intégration native dans ICU4X, ICU4J (tech preview ICU 75+), ICU4C +- Une proposition TC39 `Intl.MessageFormat` en cours pour JavaScript +- Une interopérabilité avec l'outillage existant (plateformes de traduction) + +**Pertinence pour OCaml :** MF2 est le choix naturel car : +1. Pas de base de traduction legacy à migrer --- on peut viser directement le + format d'avenir +2. La localisation asymétrique est compatible avec une vérification par contrat + à la compilation (voir section ppx_i18n) +3. ICU4X intègre MF2 --- un seul binding couvre formatage et messages +4. C'est le format vers lequel convergent Java, JavaScript et l'industrie + +## Architecture proposée + +### Vue d'ensemble + +``` + Compile-time Runtime + ──────────── ─────── + + Source OCaml ───► ppx_i18n ───► Code OCaml typé ───► ocaml-icu4x + │ │ │ + │ ▼ ▼ + │ Manifest Données CLDR + │ (clés extraites) (ICU4X blobs) + │ │ │ + │ │ Catalogues │ + │ │ (.mf2 / .xliff) │ + │ ▼ ▼ ▼ + └────────► ocaml-i18n-tool ◄────────────────────────┘ + (extract, check, convert, stats) +``` + +### Package 1 : `ocaml-icu4x` --- Bibliothèque runtime + +#### Objectif + +Fournir un accès idiomatique OCaml aux fonctionnalités ICU4X : formatage +locale-aware, collation, règles de pluriel, segmentation. Ce sont les primitives +manquantes de l'écosystème. + +#### Approche technique + +Bindings vers l'API C d'ICU4X générée par `diplomat`. Deux options : + +| Approche | Avantages | Inconvénients | +|----------|-----------|---------------| +| **ctypes** (libffi) | Pas de code C à écrire, mise à jour facile | Overhead FFI à chaque appel, pas de support no-alloc | +| **Stubs C manuels** | Performance optimale, contrôle fin de la mémoire | Plus de code à maintenir, risque de bugs mémoire | + +**Recommandation** : stubs C manuels pour les chemins critiques (formatage de +nombres, collation) et `ctypes` pour les API moins fréquemment appelées. La +frontière entre les deux pourra évoluer selon les benchmarks. + +#### Gestion des données CLDR + +ICU4X utilise un système de "data providers" avec des données CLDR +pré-compilées. Trois stratégies possibles : + +1. **Données embarquées** (recommandé par défaut) : les blobs CLDR sont compilés + dans le binaire OCaml. Augmente la taille du binaire (~2-5 Mo selon les + locales) mais élimine toute dépendance runtime. +2. **Données en fichier** : chargées depuis le système de fichiers au démarrage. + Plus flexible pour les applications supportant de nombreuses locales. +3. **Données à la carte** : seules les locales nécessaires sont embarquées. Un + outil de build (intégré à dune) génère le blob minimal. + +#### API proposée + +```ocaml +(** Locales *) +module Locale : sig + type t + + val of_string : string -> (t, [> `Invalid_locale of string]) result + (** [of_string "fr-FR"] construit une locale *) + + val to_string : t -> string + + val language : t -> string + val region : t -> string option + val script : t -> string option +end + +(** Formatage de nombres *) +module Number : sig + type formatter + + val formatter : locale:Locale.t -> unit -> formatter + (** Crée un formateur pour la locale donnée *) + + val format_int : formatter -> int -> string + val format_float : formatter -> float -> string + + (** Options de formatage *) + type options = { + min_integer_digits : int option; + min_fraction_digits : int option; + max_fraction_digits : int option; + grouping : [ `Auto | `Always | `Never | `Min2 ]; + sign_display : [ `Auto | `Never | `Always | `Except_zero ]; + } + + val formatter_with_options : + locale:Locale.t -> options:options -> unit -> formatter +end + +(** Formatage de dates *) +module Date : sig + type formatter + + type length = Short | Medium | Long | Full + + val date_formatter : locale:Locale.t -> length:length -> unit -> formatter + val time_formatter : locale:Locale.t -> length:length -> unit -> formatter + val datetime_formatter : + locale:Locale.t -> + date_length:length -> time_length:length -> + unit -> formatter + + val format : formatter -> Ptime.t -> string + (** Formate un timestamp. Compatibilité avec ptime pour l'interopérabilité. *) +end + +(** Formatage de monnaies *) +module Currency : sig + type formatter + + val formatter : locale:Locale.t -> currency:string -> unit -> formatter + (** [formatter ~locale ~currency:"EUR" ()] *) + + val format : formatter -> float -> string +end + +(** Collation *) +module Collator : sig + type t + + type strength = + | Primary (** Base characters only (a vs b) *) + | Secondary (** + accents (a vs á) *) + | Tertiary (** + case (a vs A) *) + + val create : locale:Locale.t -> strength:strength -> unit -> t + + val compare : t -> string -> string -> int + (** Comparaison locale-aware. Compatible avec [List.sort]. *) +end + +(** Règles de pluriel CLDR *) +module Plural : sig + type category = Zero | One | Two | Few | Many | Other + + type rule + + val cardinal : locale:Locale.t -> unit -> rule + val ordinal : locale:Locale.t -> unit -> rule + + val category_of_int : rule -> int -> category + val category_of_float : rule -> float -> category +end + +(** Segmentation de texte *) +module Segmenter : sig + type t + + type granularity = Grapheme | Word | Sentence | Line + + val create : locale:Locale.t -> granularity:granularity -> unit -> t + + val segments : t -> string -> string Seq.t + (** Itérateur lazy sur les segments. *) +end + +(** Liste formatée *) +module ListFormat : sig + type t + + type style = Long | Short | Narrow + type kind = And | Or | Unit + + val create : locale:Locale.t -> style:style -> kind:kind -> unit -> t + + val format : t -> string list -> string + (** [format t ["a"; "b"; "c"]] produit "a, b et c" en français *) +end +``` + +#### Relation avec les bibliothèques existantes + +`ocaml-icu4x` ne remplace pas les bibliothèques de Bünzli. Les responsabilités +sont complémentaires : + +| Besoin | Bibliothèque | +|--------|-------------| +| Codec UTF-8/UTF-16 | `uutf` | +| Propriétés de caractères | `uucp` | +| Normalisation | `uunf` | +| Segmentation simple (sans locale) | `uuseg` | +| Formatage locale-aware | **`ocaml-icu4x`** | +| Collation locale-aware | **`ocaml-icu4x`** | +| Règles de pluriel CLDR | **`ocaml-icu4x`** | +| Segmentation locale-aware | **`ocaml-icu4x`** | + +Pour la segmentation, `uuseg` reste pertinent pour les cas ne nécessitant pas de +données locale (segmentation par défaut Unicode). `ocaml-icu4x` apporte la +segmentation locale-sensitive (ex : règles de césure différentes en allemand et +en anglais). + +### Package 2 : `ppx_i18n` --- PPX de traduction type-safe + +#### Objectif + +Fournir une syntaxe concise pour les messages traduisibles avec des garanties +à la compilation que Java et JavaScript ne peuvent pas offrir. + +#### Pourquoi un PPX + +Le PPX est le mécanisme idiomatique en OCaml pour l'extension syntaxique. Pour +l'i18n, il apporte trois avantages décisifs : + +1. **Extraction statique** : les chaînes traduisibles sont identifiées et + extraites à la compilation, sans outil externe ni passe supplémentaire +2. **Typage des interpolations** : les arguments du message deviennent des + paramètres labellisés OCaml, vérifiés par le typeur +3. **Vérification de complétude** : le PPX peut avertir si une clé manque dans + un catalogue de traduction + +#### Format de messages : MessageFormat 2.0 (MF2) + +**Choix** : Unicode MessageFormat 2.0, le successeur standardisé d'ICU +MessageFormat 1.0. + +**Justification :** + +| Critère | ICU MF 1.0 (legacy) | Fluent (Mozilla) | **MF2 (choisi)** | +|---------|---------------------|-------------------|-------------------| +| Standard | Unicode ICU | Mozilla | **Unicode LDML 46** | +| Adoption | Java, C#, PHP (legacy) | Firefox, Rust | **ICU4X, ICU4J 75+, TC39 en cours** | +| Variables | Positionnelles (`{0}`) | Nommées (`$x`) | **Nommées (`$x`)** | +| Localisation asymétrique | Non | Oui | **Oui** | +| Extensibilité | Figée | Fonctions custom | **Fonctions custom (`:number`, `:datetime`)** | +| Compatible type-safety PPX | Oui | Difficile | **Oui, via vérification par contrat** | +| Outillage traducteur | Très mature | Niche | En croissance (convergence industrie) | + +MF2 combine les avantages de MF1 (standardisation, outillage) et de Fluent +(localisation asymétrique, variables nommées) tout en restant compatible avec +une vérification de type à la compilation. + +Le projet n'ayant pas de base de traduction existante à migrer, cibler +directement MF2 évite une migration future coûteuse depuis MF1. + +#### Localisation asymétrique et vérification par contrat + +MF2 permet à chaque locale de structurer ses messages librement. Pour maintenir +la type-safety, le PPX utilise un système de **contrat** : + +1. Le PPX extrait le contrat du message source : noms des arguments, leurs + types, les fonctions de formatage utilisées +2. Chaque locale implémente le message librement dans son fichier `.mf2` mais + doit respecter le contrat (mêmes arguments, mêmes types) +3. À la compilation ou en CI, `ocaml-i18n-tool` vérifie que chaque fichier + `.mf2` satisfait le contrat + +**Exemple :** le message source déclare `$count : int` : + +``` +# en.mf2 — anglais : 2 formes de pluriel +.match {$count :number} +one {{You have {$count} item.}} +* {{You have {$count} items.}} +``` + +``` +# ar.mf2 — arabe : 6 formes de pluriel, le traducteur ajoute les branches +.match {$count :number} +zero {{ليس لديك أي عنصر.}} +one {{لديك عنصر واحد.}} +two {{لديك عنصران.}} +few {{لديك {$count} عناصر.}} +many {{لديك {$count} عنصرًا.}} +* {{لديك {$count} عنصر.}} +``` + +``` +# ja.mf2 — japonais : pas de pluriel, structure totalement libre +{{アイテムが{$count}個あります。}} +``` + +Les trois fichiers respectent le contrat (`$count : int`) malgré des structures +radicalement différentes. Si un traducteur ajoute un argument `$gender` +inexistant dans le contrat, la vérification échoue. + +#### Syntaxe proposée + +**Message simple avec interpolation :** + +```ocaml +let greeting name = [%i18n "Hello, {$name}!"] +(* Expansion PPX : *) +(* val greeting : name:string -> string *) +(* Le PPX vérifie que [name] est de type string et apparaît dans le scope *) +``` + +**Pluriel (avec `.match` MF2) :** + +```ocaml +let item_count count = [%i18n {mf2| + .match {$count :number} + 0 {{You have no items.}} + one {{You have {$count} item.}} + * {{You have {$count} items.}} +|mf2}] +(* Expansion PPX : *) +(* val item_count : count:int -> string *) +(* Le PPX extrait le contrat : $count:int *) +(* Chaque locale peut définir ses propres branches de pluriel *) +``` + +**Sélection (genre, etc.) :** + +```ocaml +let invite guest gender = [%i18n {mf2| + .match {$gender :string} + female {{{$guest} invited her friends.}} + male {{{$guest} invited his friends.}} + * {{{$guest} invited their friends.}} +|mf2}] +(* Expansion PPX : *) +(* val invite : guest:string -> gender:string -> string *) +``` + +**Date et nombre formatés (fonctions MF2) :** + +```ocaml +let event_date date = [%i18n "Event on {$date :datetime dateStyle=long}."] +(* val event_date : date:Ptime.t -> string *) + +let price amount = [%i18n "Price: {$amount :number style=currency currency=EUR}."] +(* val price : amount:float -> string *) +``` + +**Contexte explicite (désambiguïsation pour les traducteurs) :** + +```ocaml +let menu_file = [%i18n ~context:"menu" "File"] +(* Clé de traduction : "menu/File" *) +(* Distingue "File" (menu) de "File" (document) *) +``` + +#### Mécanisme d'expansion + +Le PPX transforme `[%i18n "..."]` en un appel au runtime de traduction. Le +processus est le suivant : + +1. **Parse-time** : le PPX parse le message MF2 et extrait : + - La liste des arguments avec leurs types (`string`, `int`, `float`, + `Ptime.t`), déduits des fonctions de formatage (`:number` → `int`/`float`, + `:datetime` → `Ptime.t`, etc.) + - Le contrat du message (arguments + types) pour la vérification inter-locales + - Une clé stable (hash du message source ou identifiant explicite) + +2. **Génération de code** : le PPX produit une fonction OCaml typée : + +```ocaml +(* [%i18n "Hello, {$name}!"] devient : *) +I18n_runtime.format + ~key:"a1b2c3d4" + ~default:"Hello, {$name}!" + ~args:[("name", I18n_runtime.String name)] + () +``` + +3. **Extraction side-effect** : en mode extraction (variable d'environnement ou + flag dune), le PPX écrit les clés et leurs contrats dans un fichier manifest + `.i18n-manifest` utilisé par `ocaml-i18n-tool`. + +#### Vérification de complétude + +En mode strict (configurable par dune), le PPX émet un warning ou une erreur +si une clé extraite ne se trouve pas dans les catalogues de traduction +configurés : + +``` +File "src/ui.ml", line 42, characters 10-50: +Warning 901 [i18n-missing]: translation key "a1b2c3d4" not found + in locale "fr" (catalog: locales/fr.mf2) + Source message: "Hello, {$name}!" +``` + +Cela permet de détecter les traductions manquantes dans la CI, avant le +déploiement. + +#### Comparaison avec gettext + +| Aspect | `ocaml-gettext` | `ppx_i18n` (MF2) | +|--------|-----------------|------------------| +| Marquage des chaînes | `s_ "Hello %s"` | `[%i18n "Hello, {$name}!"]` | +| Typage des arguments | Aucun (Printf runtime) | Compile-time (paramètres labellisés) | +| Pluriel | `sn_ "item" "items" n` (2 formes) | MF2 `.match` (6 catégories CLDR, asymétrique par locale) | +| Genre / select | Non supporté | MF2 `.match` avec sélecteurs custom | +| Formatage | Aucun | Fonctions MF2 (`:number`, `:datetime`, etc.) via ICU4X | +| Localisation asymétrique | Non | Oui (chaque locale structure librement) | +| Extraction | Outil externe (`ocaml-gettext` CLI) | Intégré au PPX (manifest + contrats) | +| Détection manquants | Non | Warning/erreur à la compilation | + +### Package 3 : `ocaml-i18n-tool` --- Outil CLI + +#### Objectif + +Gérer le workflow humain de traduction : extraction, validation, conversion +entre formats, statistiques de couverture. Cet outil s'intègre dans la CI et +avec les plateformes de traduction tierces. + +#### Pourquoi un outil séparé + +- L'extraction peut être pilotée par le PPX, mais la gestion des catalogues + (merge, validation, stats) est un workflow distinct de la compilation +- Doit s'intégrer avec les plateformes de traduction existantes (Weblate, + Crowdin, Transifex) qui travaillent avec des formats de fichiers standard +- Ne doit pas alourdir le temps de compilation + +#### Commandes + +**Extraction des messages :** + +```bash +# Lit les manifests générés par ppx_i18n et produit un catalogue template +opam exec -- ocaml-i18n extract \ + --manifest=_build/default/.i18n-manifest \ + --out=locales/en.mf2 +``` + +**Vérification de complétude :** + +```bash +# Vérifie que toutes les clés sont traduites dans toutes les locales +opam exec -- ocaml-i18n check \ + --source=locales/en.mf2 \ + --locales=locales/ \ + --warn-missing \ + --warn-unused \ + --fail-under=90 # Échoue si couverture < 90% +``` + +Sortie typique : + +``` +Locale Translated Missing Unused Coverage +────── ────────── ─────── ────── ──────── +fr 142/150 8 0 94.7% +de 98/150 52 3 65.3% +ja 0/150 150 0 0.0% + +ERROR: locale "de" coverage 65.3% < threshold 90% +``` + +**Conversion entre formats :** + +```bash +# MF2 → XLIFF (pour intégration avec des outils de traduction d'entreprise) +opam exec -- ocaml-i18n convert --from=mf2 --to=xliff locales/fr.mf2 + +# XLIFF → MF2 (retour depuis l'outil de traduction) +opam exec -- ocaml-i18n convert --from=xliff --to=mf2 locales/fr.xliff + +# MF2 → PO (interopérabilité avec l'écosystème gettext si nécessaire) +opam exec -- ocaml-i18n convert --from=mf2 --to=po locales/fr.mf2 +``` + +**Merge de catalogues :** + +```bash +# Met à jour un catalogue existant avec les nouvelles clés du source +# (préserve les traductions existantes, marque les obsolètes) +opam exec -- ocaml-i18n merge \ + --source=locales/en.mf2 \ + --catalog=locales/fr.mf2 +``` + +**Statistiques détaillées :** + +```bash +opam exec -- ocaml-i18n stats --locales=locales/ --by-file +``` + +#### Formats supportés + +| Format | Usage | Phase | +|--------|-------|-------| +| **MF2** (Unicode LDML 46) | Format natif. Un fichier `.mf2` par locale contenant les messages MF2. | Phase 1 | +| **XLIFF 1.2/2.0** | Standard OASIS pour l'industrie de la traduction. Compatible SDL Trados, memoQ. Export/import pour les plateformes de traduction. | Phase 2 | +| **PO/POT** (GNU gettext) | Interopérabilité avec l'écosystème gettext (Weblate, Poedit). Export/import. | Phase 2 | +| **JSON** (i18next, FormatJS) | Écosystème JavaScript, utile pour les projets hybrides OCaml/JS (js_of_ocaml). | Phase 3 | + +#### Intégration dune + +L'outil s'intègre avec dune via une règle personnalisée : + +```lisp +; dune +(rule + (alias check-i18n) + (deps (glob_files locales/*.mf2)) + (action + (run ocaml-i18n check + --source locales/en.mf2 + --locales locales/ + --fail-under 95))) +``` + +Cela permet d'intégrer la vérification i18n dans `dune build @check-i18n` et +dans la CI. + +## Cas d'utilisation + +### Cas 1 : Application CLI multilingue + +```ocaml +(* main.ml *) +let () = + (* Initialisation avec la locale système *) + let locale = I18n.Locale.of_system () in + I18n.init ~locale ~catalog_dir:"./locales" (); + + let name = Sys.argv.(1) in + let count = int_of_string Sys.argv.(2) in + + (* Messages traduits et typés *) + print_endline ([%i18n "Hello, {$name}!"] ~name); + print_endline ([%i18n {mf2| + .match {$count :number} + 0 {{No files in your inbox.}} + one {{One file in your inbox.}} + * {{{$count} files in your inbox.}} + |mf2}] ~count); + + (* Formatage locale-aware *) + let fmt = Icu.Number.formatter ~locale () in + Printf.printf "Total: %s\n" (Icu.Number.format_int fmt 1_234_567) + (* fr-FR: "Total: 1 234 567" *) + (* en-US: "Total: 1,234,567" *) + (* de-DE: "Total: 1.234.567" *) +``` + +### Cas 2 : Application web (Dream + js_of_ocaml) + +```ocaml +(* server.ml *) +let handler req = + (* Détecte la locale depuis Accept-Language *) + let locale = I18n.Locale.of_accept_language + (Dream.header req "Accept-Language") in + I18n.with_locale locale @@ fun () -> + + let user = get_current_user req in + let balance = get_balance user in + + let currency_fmt = + Icu.Currency.formatter ~locale ~currency:"EUR" () in + + Dream.html + ([%i18n "Welcome back, {$name}!"] ~name:user.name ^ "\n" ^ + [%i18n "Your balance: {$balance :number style=currency currency=EUR}."] + ~balance) +``` + +### Cas 3 : Bibliothèque avec messages d'erreur traduisibles + +```ocaml +(* errors.ml *) + +(* Les messages d'erreur sont traduisibles sans forcer une locale *) +type error = + | File_not_found of string + | Permission_denied of { path: string; user: string } + +let error_message = function + | File_not_found path -> + [%i18n "File not found: {$path}."] ~path + | Permission_denied { path; user } -> + [%i18n "Permission denied: {$user} cannot access {$path}."] ~user ~path +``` + +### Cas 4 : Intégration CI + +```yaml +# .github/workflows/i18n.yml +name: i18n check +on: [push, pull_request] +jobs: + check-translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ocaml/setup-ocaml@v3 + - run: opam exec -- dune build + - run: opam exec -- ocaml-i18n check + --source locales/en.mf2 + --locales locales/ + --fail-under 95 + --warn-unused +``` + +## Alternatives considérées + +### Alternative 1 : Bindings ICU4C au lieu d'ICU4X + +ICU4C est la bibliothèque C historique du projet ICU, utilisée par de nombreux +systèmes (glibc, macOS, Node.js). + +**Rejeté car :** +- Dépendance système lourde (~30 Mo de données) difficile à distribuer via opam +- API C complexe et instable entre versions majeures +- Nécessite une installation système (`apt install libicu-dev`) qui complique + l'onboarding +- ICU4X est le successeur officiel avec une API C conçue pour le FFI + +### Alternative 2 : Implémentation native OCaml du formatage CLDR + +Implémenter directement en OCaml le formatage de nombres, dates, etc. en se +basant sur les données CLDR brutes. + +**Rejeté car :** +- Effort considérable pour atteindre la conformité (CLDR fait ~40 Mo de données + XML couvrant des centaines de locales) +- Maintenance permanente à chaque release CLDR (semestrielle) +- Bugs subtils de formatage inévitables sans suite de tests de conformité +- ICU4X fait exactement cela, avec une équipe dédiée et des tests de conformité + +### Alternative 3 : Extension d'ocaml-gettext plutôt qu'un nouveau PPX + +Ajouter la type-safety et MF2 directement à `ocaml-gettext`. + +**Rejeté car :** +- L'API `s_`/`f_` de gettext est fondamentalement non-typée +- Le modèle gettext (2 formes de pluriel via `ngettext`) ne couvre pas les + besoins CLDR (6 catégories) +- Le format PO n'est pas conçu pour la localisation asymétrique de MF2 +- L'architecture extraction+vérification compile-time nécessite un PPX dédié + +L'interopérabilité avec le format PO reste possible via `ocaml-i18n-tool +convert` pour les projets ayant un historique gettext. + +### Alternative 4 : ICU MessageFormat 1.0 comme format de messages + +ICU MessageFormat 1.0 est le format legacy le plus largement déployé +(Java, C#, PHP, Go). + +**Rejeté car :** +- MF2 est le successeur officiel, publié par le Unicode Consortium (LDML 46) +- MF1 utilise des variables positionnelles (`{0}`) plutôt que nommées (`{$x}`) +- MF1 ne supporte pas la localisation asymétrique (chaque locale doit suivre + la structure de la langue source) +- Le projet n'a pas de base de traduction MF1 à migrer --- aucun coût de + rétrocompatibilité à supporter +- L'industrie converge vers MF2 (TC39 `Intl.MessageFormat`, ICU4X, ICU4J 75+) + +### Alternative 5 : Fluent (Mozilla) comme format de messages principal + +Project Fluent offre un modèle de localisation asymétrique expressif. + +**Rejeté en faveur de MF2 car :** +- MF2 intègre la localisation asymétrique de Fluent tout en étant un standard + Unicode officiel +- Fluent reste niche (principalement Mozilla/Firefox), là où MF2 est soutenu + par le Unicode Consortium et adopté par ICU4X, ICU4J, et TC39 +- L'outillage traducteur pour Fluent est limité comparé à l'écosystème + ICU/XLIFF vers lequel MF2 converge +- MF2 bénéficie directement des implémentations ICU4X que nous utilisons + déjà pour le formatage + +### Alternative 6 : Pas de PPX, juste une bibliothèque runtime + +Fournir uniquement `ocaml-icu4x` avec une API pour charger des catalogues et +formater des messages, sans extension syntaxique. + +**Rejeté car :** +- On perd l'avantage compétitif d'OCaml : la vérification de type à la + compilation +- L'expérience développeur régresse au niveau de Java (`MessageFormat.format` + avec des erreurs runtime) +- L'extraction automatique des chaînes nécessiterait un outil externe parsant + l'AST OCaml --- essentiellement un PPX déguisé + +## Plan d'implémentation + +### Phase 1 : Fondations (estimation : ~3 mois) + +**Objectif** : valeur utilisable minimale avec MF2 dès le départ. + +| Livrable | Description | +|----------|-------------| +| `ocaml-icu4x` v0.1 | Bindings Locale + Plural (nécessaire pour le runtime MF2) | +| `ppx_i18n` v0.1 | PPX avec parsing MF2, interpolation typée, extraction de contrats | +| Runtime MF2 | Résolution des messages MF2 avec sélection pluriel/select via ICU4X | +| `ocaml-i18n-tool` v0.1 | Extraction depuis manifests PPX, génération de fichiers `.mf2`, vérification de contrats | + +À ce stade, les messages sont traduits et interpolés avec le système MF2 +complet (pluriel, select, localisation asymétrique). Le formatage locale-aware +des nombres et dates dans le texte libre (hors messages MF2) n'est pas encore +disponible. + +### Phase 2 : Formatage locale-aware (estimation : ~4 mois) + +**Objectif** : combler la lacune critique du formatage. + +| Livrable | Description | +|----------|-------------| +| `ocaml-icu4x` v0.2 | Bindings Number + Date + Currency (fonctions MF2 `:number`, `:datetime`) | +| `ppx_i18n` v0.2 | Support des fonctions de formatage MF2 dans les messages, vérification compile-time des options | +| `ocaml-i18n-tool` v0.2 | Check de complétude, statistiques, export XLIFF | + +### Phase 3 : Maturité (estimation : ~3 mois) + +**Objectif** : parité fonctionnelle avec les solutions Java/JS. + +| Livrable | Description | +|----------|-------------| +| `ocaml-icu4x` v0.3 | Collation, ListFormat, Segmenter | +| `ppx_i18n` v0.3 | Vérification de complétude compile-time, contextes, optimisations | +| `ocaml-i18n-tool` v0.3 | Conversion multi-format (MF2/XLIFF/PO), merge de catalogues, intégration dune | + +### Phase 4 : Extensions (optionnelle) + +| Livrable | Description | +|----------|-------------| +| Calendriers | Calendriers non-grégoriens via ICU4X | +| Plugin Merlin/LSP | Autocomplétion des clés de traduction, navigation vers les fichiers `.mf2` | +| `js_of_ocaml` compat | Backend utilisant l'API `Intl` du navigateur au lieu d'ICU4X | +| Fonctions MF2 custom | Permettre aux utilisateurs de définir leurs propres fonctions de formatage MF2 | + +## Questions ouvertes + +### Q1 : Gestion de la locale courante + +Comment gérer la locale courante dans un programme OCaml ? + +- **Option A** : variable globale thread-local (simple, style gettext) +- **Option B** : paramètre explicite passé aux fonctions (pur, mais verbeux) +- **Option C** : module `I18n.with_locale` utilisant un "ambient context" + (domain-local via Effect handlers d'OCaml 5 ?) + +L'option C est la plus intéressante car elle combine la praticité de A avec la +pureté de B, en utilisant les capacités d'OCaml 5. Mais elle exclut OCaml 4. + +### Q2 : Stabilité des clés de traduction + +Comment générer des clés stables pour les messages ? + +- **Option A** : hash du message source (automatique, mais fragile si le + message source change) +- **Option B** : identifiant explicite obligatoire (`[%i18n ~id:"greeting" "Hello!"]`) +- **Option C** : le message source lui-même est la clé (approche gettext + classique) + +L'option C est la plus pragmatique pour la phase 1 (compatible gettext). L'option +B peut être ajoutée comme alternative opt-in. + +### Q3 : Granularité des données CLDR embarquées + +Comment minimiser la taille des données CLDR dans le binaire ? + +ICU4X supporte le "data slicing" --- n'embarquer que les données nécessaires +pour les locales et fonctionnalités utilisées. Faut-il : + +- Embarquer toutes les locales par défaut et laisser l'utilisateur optimiser ? +- Exiger une configuration explicite des locales supportées ? +- Détecter automatiquement les locales utilisées dans le code ? + +### Q4 : Compatibilité OCaml 4 vs OCaml 5 + +`ppx_i18n` et `ocaml-icu4x` devraient supporter OCaml 4.14+ pour maximiser +l'adoption. Mais la question Q1 (ambient locale via effects) bénéficierait +d'OCaml 5. + +**Proposition** : supporter OCaml 4.14+ avec une API explicite (locale passée +en paramètre), et fournir un module additionnel `I18n_eio` ou `I18n_effect` +pour OCaml 5 avec un ambient context basé sur les effects. + +### Q5 : Interaction avec le système de build + +Comment intégrer l'extraction et la vérification dans dune de manière ergonomique ? + +- Règle dune custom (comme proposé dans cette RFC) ? +- Plugin dune dédié ? +- Les deux ? + +## Références + +### Standards Unicode +- [MessageFormat 2.0 (LDML 46, Part 9)](https://www.unicode.org/reports/tr35/tr35-messageFormat.html) --- Spécification MF2 +- [MessageFormat 2.0 Working Group](https://github.com/unicode-org/message-format-wg) --- Groupe de travail et discussions +- [CLDR](https://cldr.unicode.org/) --- Unicode Common Locale Data Repository +- [ICU4X](https://github.com/unicode-org/icu4x) --- Implémentation Rust de référence (formatage + MF2) +- [diplomat](https://github.com/rust-diplomat/diplomat) --- Outil de génération FFI pour Rust (utilisé par ICU4X) + +### Écosystème OCaml +- [ocaml-gettext](https://github.com/gildor478/ocaml-gettext) --- Bindings GNU gettext pour OCaml +- [uucp](https://erratique.ch/software/uucp) --- Unicode character properties pour OCaml +- [uuseg](https://erratique.ch/software/uuseg) --- Unicode text segmentation pour OCaml + +### Implémentations de référence dans d'autres langages +- [TC39 Intl.MessageFormat proposal](https://github.com/tc39/proposal-intl-messageformat) --- MF2 pour JavaScript (Stage 1) +- [ICU4J MessageFormat 2.0](https://unicode-org.github.io/icu/userguide/format_parse/messages/) --- Tech preview dans ICU 75+ +- [Intl API (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) --- API i18n JavaScript +- [ICU4J](https://unicode-org.github.io/icu/userguide/icu4j/) --- ICU pour Java +- [Babel](https://babel.pocoo.org/) --- i18n pour Python +- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) --- i18n pour Go + +### Alternatives évaluées +- [ICU MessageFormat 1.0](https://unicode-org.github.io/icu/userguide/format_parse/messages/) --- Format legacy (remplacé par MF2) +- [Project Fluent](https://projectfluent.org/) --- Format Mozilla (idées reprises par MF2)