My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OxCaml mode rendering investigation and design doc

Investigation identifies three discrepancies between odoc and toplevel
rendering of OxCaml modes/jkinds. Design doc covers fixes for redundant
jkind annotations and spurious return modes, plus a new --mode-links
flag for linking mode names to external documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+428
+185
docs/plans/2026-03-02-oxcaml-investigation.md
··· 1 + # OxCaml Modes/Layouts Discrepancy Investigation 2 + 3 + **Date:** 2026-03-03 4 + **Status:** Complete — three bugs identified, recommendations below 5 + 6 + ## Overview 7 + 8 + This investigation compares how odoc (the monorepo's custom build with OxCaml 9 + mode/layout support) and the OCaml toplevel render OxCaml type annotations. 10 + The test case is `Base.Uniform_array.compare__local`, derived by 11 + `ppx_compare` from `type ('a : value_or_null) t [@@deriving compare ~localize]`. 12 + 13 + ## Source type 14 + 15 + The ppx generates (via `ocamlfind ocamlc -dsource`): 16 + 17 + ```ocaml 18 + val compare__local : 19 + ('a : value_or_null) . 20 + (local_ ('a : value_or_null) -> 21 + local_ ('a : value_or_null) -> int) 22 + -> 23 + local_ ('a : value_or_null) t -> 24 + local_ ('a : value_or_null) t -> int 25 + ``` 26 + 27 + Key features of the generated type: 28 + - Explicit universal quantification: `('a : value_or_null) .` 29 + - Jkind annotation on every occurrence of `'a`: `('a : value_or_null)` 30 + - `local_` mode on function arguments 31 + 32 + ## What the toplevel shows 33 + 34 + ``` 35 + - : ('a @ local -> 'a @ local -> int) -> 36 + 'a Base.Uniform_array.t @ local -> 'a Base.Uniform_array.t @ local -> int 37 + ``` 38 + 39 + The toplevel (`Printtyp`): 40 + - Elides the `'a.` universal quantification (implicit for value types) 41 + - Elides the `('a : value_or_null)` jkind (inferred from usage in `'a t`) 42 + - Shows `@ local` only on arguments, not on inner return types 43 + - Renders `local_` as postfix `@ local` 44 + 45 + ## What odoc shows 46 + 47 + ``` 48 + val compare__local : 49 + 'a. 50 + (('a : value_or_null) @ local -> 51 + (('a : value_or_null) @ local -> int) @ local) -> 52 + ('a : value_or_null) Test_derive.t @ local -> 53 + (('a : value_or_null) Test_derive.t @ local -> int) @ local 54 + ``` 55 + 56 + ## Discrepancies 57 + 58 + ### 1. Explicit `'a.` quantification — cosmetic, low priority 59 + 60 + **odoc:** Shows `'a. ...` 61 + **toplevel:** Omits it 62 + 63 + **Analysis:** odoc faithfully renders the `Tpoly` node from the CMI. The 64 + toplevel's `Printtyp` elides explicit quantification for non-method, 65 + non-GADT types because in standard OCaml, `val f : 'a -> 'a` and 66 + `val f : 'a. 'a -> 'a` are identical. In OxCaml, the `Tpoly` node carries 67 + jkind information, so the quantifier serves a purpose (constraining the 68 + variable's jkind). Both renderings are technically correct. 69 + 70 + **Verdict:** Acceptable difference. If anything, odoc could omit the `'a.` 71 + when all quantified variables have the default `value` jkind, matching the 72 + toplevel. But showing it is not wrong. 73 + 74 + ### 2. Redundant `('a : value_or_null)` on every occurrence — **odoc bug** 75 + 76 + **odoc:** `('a : value_or_null)` on *every* use of `'a` 77 + **toplevel:** Just `'a` (jkind stated once at quantifier, if shown at all) 78 + 79 + **Root cause:** The ppx generates the jkind annotation at every occurrence of 80 + the type variable in the source AST. After type-checking, the `Tunivar` nodes 81 + in the CMI each carry the jkind. odoc's `extract_jkind_of_tvar` extracts the 82 + jkind from every `Tunivar`/`Tvar` node independently, causing redundant 83 + annotations. 84 + 85 + The toplevel's `Printtyp` states the jkind only at the binding site (the 86 + universal quantifier) and elides it at use sites. 87 + 88 + **Code location:** `odoc/src/loader/cmi.cppo.ml:614-617` — the `Tvar` 89 + branch applies `extract_jkind_of_tvar` unconditionally. It should only 90 + extract jkinds at the binding site (the `Tpoly` quantifier variables) or 91 + suppress duplicates. 92 + 93 + ```ocaml 94 + (* Current code — annotates EVERY occurrence *) 95 + | Tvar { name; jkind } -> 96 + let nm = match name with Some n -> n | None -> name_of_type typ in 97 + if nm = "_" then Any 98 + else Var (nm, extract_jkind_of_tvar jkind) 99 + ``` 100 + 101 + **Fix options:** 102 + 103 + a. **Track quantifier-bound variables:** In the `Tpoly` branch, record which 104 + variables are being universally quantified, and only emit their jkinds in 105 + the `Poly(vars, ...)` rendering, not at each `Var` use site. This matches 106 + the toplevel's behaviour. 107 + 108 + b. **De-duplicate in the renderer:** In `generator.ml`, when rendering 109 + `Var(name, Some jkind)`, check whether the variable is already bound by an 110 + enclosing `Poly` and skip the jkind annotation if so. 111 + 112 + c. **Only show jkind at `Tunivar` (quantifier binding), not `Tvar` (use):** 113 + Currently both branches emit the jkind. The `Tvar` branch could always 114 + return `Var(nm, None)`, leaving jkind annotations to the `Tunivar` branch 115 + (line 678) only. 116 + 117 + **Recommendation:** Option (c) is simplest — change line 617 to 118 + `Var (nm, None)`, keeping the annotation only at `Tunivar` sites. But this 119 + must be paired with fixing the `Poly` rendering (discrepancy #1) to include 120 + the jkind on the binding variable, i.e. change `'a.` to `('a : jkind).` 121 + when a jkind is present. 122 + 123 + ### 3. Spurious `@ local` on return types — **odoc bug** 124 + 125 + **odoc:** `(... -> int) @ local` 126 + **toplevel:** `... -> ... -> int` (no mode on inner return) 127 + 128 + **Analysis:** The OCaml type `Tarrow((lbl, marg, mret), arg, res, _)` has 129 + separate mode annotations for the argument (`marg`) and the return (`mret`). 130 + In OxCaml's mode system, a function `'a @ local -> int` means the argument 131 + has mode `local`, but this does NOT mean the entire arrow expression has mode 132 + `local`. 133 + 134 + odoc's `extract_arg_modes` extracts modes from both `marg` and `mret`. The 135 + renderer then places `mret` modes after the return type: 136 + ``` 137 + arg @ marg -> ret @ mret 138 + ``` 139 + 140 + But the toplevel's `Printtyp.tree_of_modes` only shows the modes that are 141 + semantically visible to the user. For a multi-argument function like 142 + `a @ local -> b @ local -> int`, the return mode of the first arrow is about 143 + the mode of the closure `b @ local -> int`, which is implicitly `local` 144 + because a `local` function returns a `local` closure. Showing this is 145 + redundant. 146 + 147 + **Code location:** `odoc/src/document/generator.ml:461-468` (and 493-499) — 148 + the `ret_modes` suffix is always rendered when non-empty. 149 + 150 + **Fix:** Apply the same elision rules as `Printtyp`. Specifically: 151 + - When the return type is itself an `Arrow`, the return mode is implied by 152 + the argument mode of the containing arrow, and should not be shown. 153 + - Only show return modes on the outermost arrow (the final return type), 154 + and only when they differ from the default. 155 + 156 + This requires checking whether `mret` is the "implied default" given the 157 + argument mode. The toplevel's `Printtyp.tree_of_modes` in OxCaml implements 158 + this logic. 159 + 160 + ## Summary 161 + 162 + | # | Issue | Severity | Fix complexity | 163 + |---|-------|----------|---------------| 164 + | 1 | `'a.` explicit quantification shown | Low (cosmetic) | Easy — elide when jkind is default `value` | 165 + | 2 | `('a : value_or_null)` on every `'a` | **Medium (bug)** | Easy — stop extracting jkind from `Tvar` | 166 + | 3 | Spurious `@ local` on return types | **Medium (bug)** | Medium — replicate `Printtyp` elision logic | 167 + 168 + ## Recommendation 169 + 170 + Issues #2 and #3 are bugs that should be fixed. Issue #1 is acceptable but 171 + could optionally be improved. 172 + 173 + For #2, the simplest fix is to change `cmi.cppo.ml:617` from 174 + `Var (nm, extract_jkind_of_tvar jkind)` to `Var (nm, None)`, and then update 175 + the `Poly` rendering in `generator.ml:522` to include jkind annotations from 176 + the `Tunivar` nodes at the quantifier binding site. 177 + 178 + For #3, the fix requires understanding OxCaml's mode elision rules. A 179 + pragmatic approach is to suppress return modes when the return type is itself 180 + an `Arrow` (since inner arrow return modes are always implied by the 181 + enclosing context). A more thorough fix would replicate the full 182 + `Printtyp.tree_of_modes` logic. 183 + 184 + Neither fix is urgent — the output is verbose but not incorrect. The types 185 + are semantically correct, just more annotated than what users expect.
+243
docs/plans/2026-03-03-oxcaml-mode-rendering-design.md
··· 1 + # OxCaml Mode Rendering Fixes and Mode Links 2 + 3 + **Date:** 2026-03-03 4 + 5 + ## Problem 6 + 7 + odoc's OxCaml mode/jkind rendering has two bugs and lacks a feature that 8 + would make mode annotations more useful in documentation. 9 + 10 + **Bug 1 — Redundant jkind annotations.** The ppx-derived type: 11 + 12 + ```ocaml 13 + val compare__local : 14 + ('a : value_or_null) . 15 + (local_ ('a : value_or_null) -> local_ ('a : value_or_null) -> int) 16 + -> local_ ('a : value_or_null) t -> local_ ('a : value_or_null) t -> int 17 + ``` 18 + 19 + renders in odoc with `('a : value_or_null)` at every occurrence of `'a`. 20 + The toplevel correctly shows the jkind only at the binding site. 21 + 22 + **Bug 2 — Spurious return modes.** For a multi-argument function with 23 + `@ local` arguments, odoc shows `@ local` on inner return types (the 24 + partial-application closures). These are always implied — a closure 25 + capturing a local value is necessarily local. The toplevel elides them. 26 + 27 + **Current odoc output:** 28 + ``` 29 + val compare__local : 30 + 'a. 31 + (('a : value_or_null) @ local -> 32 + (('a : value_or_null) @ local -> int) @ local) -> 33 + ('a : value_or_null) t @ local -> 34 + (('a : value_or_null) t @ local -> int) @ local 35 + ``` 36 + 37 + **Desired odoc output:** 38 + ``` 39 + val compare__local : 40 + ('a : value_or_null). 41 + ('a @ local -> 'a @ local -> int) -> 42 + 'a t @ local -> 'a t @ local -> int 43 + ``` 44 + 45 + **Missing feature — mode links.** Mode names like `local`, `portable`, 46 + `value_or_null` are opaque to many readers. Linking them to external 47 + documentation would make odoc output more useful. 48 + 49 + ## Design 50 + 51 + ### Part 1: Rendering fixes (loader) 52 + 53 + Both fixes live in the loader (`cmi.cppo.ml`), which has access to the 54 + OxCaml `Mode` module for correct semantic decisions. 55 + 56 + #### Fix 1: Jkind deduplication 57 + 58 + **Principle:** Jkind annotations belong at the binding site (the universal 59 + quantifier), not at every use of the type variable. 60 + 61 + **Change in `cmi.cppo.ml`:** The `Tvar` branch (line ~614) currently calls 62 + `extract_jkind_of_tvar` on every type variable occurrence. Change it to 63 + always return `Var (nm, None)`. Jkind extraction remains only at the 64 + `Tunivar` branch (line ~678), which corresponds to quantifier binding sites. 65 + 66 + ```ocaml 67 + (* Tvar: use site — no jkind annotation needed. 68 + The jkind is stated once at the Tunivar binding site 69 + in the enclosing Tpoly, matching Printtyp's behaviour. *) 70 + | Tvar { name; _ } -> 71 + let nm = match name with Some n -> n | None -> name_of_type typ in 72 + if nm = "_" then Any 73 + else Var (nm, None) 74 + ``` 75 + 76 + **Change in `lang.ml`:** The `Poly` variant needs to carry optional jkinds 77 + for the quantified variables: 78 + 79 + ```ocaml 80 + (* Before *) 81 + | Poly of string list * t 82 + 83 + (* After — name * optional jkind, same as Var *) 84 + | Poly of (string * string option) list * t 85 + ``` 86 + 87 + **Change in loader `Tpoly` branch:** Extract jkinds from the quantified 88 + `Tunivar` nodes and include them in the `Poly` variant: 89 + 90 + ```ocaml 91 + | Tpoly (typ, tyl) -> 92 + let tyl = List.map Compat.repr tyl in 93 + let vars = List.map (fun ty -> 94 + let name = name_of_type_repr ty in 95 + let jkind = match ty.desc with 96 + | Tunivar { jkind; _ } -> extract_jkind_of_tvar jkind 97 + | _ -> None 98 + in 99 + (name, jkind)) tyl 100 + in 101 + let typ = read_type_expr env typ in 102 + remove_names (List.map fst_repr tyl); 103 + Poly(vars, typ) 104 + ``` 105 + 106 + **Change in `generator.ml`:** Update `Poly` rendering (line ~522) from: 107 + 108 + ```ocaml 109 + | Poly (polyvars, t) -> 110 + O.txt ("'" ^ String.concat ~sep:" '" polyvars ^ ". ") ++ type_expr t 111 + ``` 112 + 113 + to rendering each variable with its jkind when present: 114 + 115 + ```ocaml 116 + | Poly (polyvars, t) -> 117 + let render_var (name, jkind) = match jkind with 118 + | None -> "'" ^ name 119 + | Some jk -> "('" ^ name ^ " : " ^ jk ^ ")" 120 + in 121 + O.txt (String.concat ~sep:" " (List.map render_var polyvars) ^ ". ") 122 + ++ type_expr t 123 + ``` 124 + 125 + #### Fix 2: Return mode elision 126 + 127 + **Principle:** When a function's return type is itself an arrow, the return 128 + mode is implied by the argument mode (a closure capturing a local value is 129 + necessarily local). Suppress it to match Printtyp's behaviour. 130 + 131 + **Change in `cmi.cppo.ml`:** In the `Tarrow` branch, check whether the 132 + return type is another `Tarrow`. If so, set `ret_modes` to `[]`: 133 + 134 + ```ocaml 135 + | Tarrow((lbl, marg, mret), arg, res, _) -> 136 + let arg_modes = extract_arg_modes marg in 137 + (* Suppress return modes when the return type is itself a function. 138 + The mode of a partial-application closure is always implied by the 139 + modes of its captured arguments — showing it is redundant. 140 + This matches the elision logic in Printtyp.tree_of_modes. *) 141 + let ret_modes = match Compat.get_desc res with 142 + | Tarrow _ -> [] 143 + | _ -> extract_arg_modes mret 144 + in 145 + ``` 146 + 147 + ### Part 2: Mode links (`--mode-links`) 148 + 149 + A new flag on `odoc html-generate` that makes mode and jkind names into 150 + hyperlinks to external documentation. 151 + 152 + #### CLI 153 + 154 + ``` 155 + odoc html-generate --mode-links URI ... 156 + ``` 157 + 158 + Where `URI` is a base URL. The mode name becomes the fragment: 159 + `local` → `URI#local`, `value_or_null` → `URI#value_or_null`. Fragment 160 + is the name exactly as rendered, no normalisation. 161 + 162 + When `--mode-links` is not provided, modes render as plain text (current 163 + behaviour minus the bugs fixed in Part 1). 164 + 165 + #### Config 166 + 167 + Add `mode_links : string option` to `Config.t` in `config.ml` / `.mli`. 168 + This is a plain string (not `Types.uri`) because it's always an absolute 169 + external URL — no relative path resolution against the output directory. 170 + 171 + ```ocaml 172 + (* In config.ml, add to the record type: *) 173 + mode_links : string option; 174 + 175 + (* In v, add parameter: *) 176 + ?mode_links:string -> 177 + 178 + (* Accessor: *) 179 + let mode_links config = config.mode_links 180 + ``` 181 + 182 + #### Cmdliner argument 183 + 184 + In `main.ml`, add alongside the other URI arguments in `Odoc_html_args`: 185 + 186 + ```ocaml 187 + let mode_links = 188 + let doc = 189 + "Base URI for mode and jkind documentation links. Mode names become \ 190 + the fragment, e.g. $(b,--mode-links https://example.com/modes) makes \ 191 + $(i,local) link to $(i,https://example.com/modes#local)." 192 + in 193 + Arg.(value & opt (some string) None & info [ "mode-links" ] ~docv:"URI" ~doc) 194 + ``` 195 + 196 + Wire it through the `config` function to `Config.v`. 197 + 198 + #### Rendering 199 + 200 + In `generator.ml`, where modes are currently rendered as: 201 + 202 + ```ocaml 203 + O.txt (String.concat ~sep:" " ms) 204 + ``` 205 + 206 + Change to render each mode individually. When `mode_links` is configured, 207 + emit each mode as a linked element; otherwise emit as plain text. 208 + 209 + The `@` / `@@` keyword stays unlinked — only mode names (`local`, 210 + `portable`) and jkind names (`value_or_null`, `float64`) become links. 211 + 212 + #### HTML output 213 + 214 + Links get a `mode-link` CSS class for optional styling: 215 + 216 + ```html 217 + <span class="keyword">@</span> <a class="mode-link" href="https://example.com/modes#local">local</a> 218 + ``` 219 + 220 + Default styling inherits standard link appearance. Projects can customise 221 + via `a.mode-link` in their CSS. 222 + 223 + ## Files changed 224 + 225 + | File | Change | 226 + |------|--------| 227 + | `odoc/src/model/lang.ml` | `Poly` variant carries `(string * string option) list` | 228 + | `odoc/src/loader/cmi.cppo.ml` | Jkind dedup on `Tvar`, return mode elision on `Tarrow`, jkind extraction in `Tpoly` | 229 + | `odoc/src/html/config.ml` + `.mli` | Add `mode_links` field | 230 + | `odoc/src/odoc/bin/main.ml` | Add `--mode-links` cmdliner arg | 231 + | `odoc/src/document/generator.ml` | Update `Poly` rendering, mode/jkind linking | 232 + | `odoc/src/html/generator.ml` | Emit `<a class="mode-link">` for linked modes | 233 + | `odoc/test/integration/oxcaml_modes.t/` | Update expected output | 234 + 235 + ## Not in scope 236 + 237 + - Mode elision based on semantic implication rules beyond the structural 238 + arrow check (e.g. "local implies unforkable"). The loader's existing 239 + `extract_arg_modes` already handles per-axis elision; the structural 240 + check added here covers the remaining case. 241 + - Changes to `lang.ml` mode representation beyond the `Poly` variant. 242 + Modes remain `string list`. 243 + - Relative URI resolution for `--mode-links`. It's always absolute.