this repo has no description

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.