this repo has no description

Add implementation plan for OxCaml mode rendering fixes and mode links

Nine tasks covering: Poly type change, jkind dedup in loader, return
mode elision, generator update, integration test, --mode-links flag,
and end-to-end verification.

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

+822
+822
docs/plans/2026-03-03-oxcaml-mode-rendering-plan.md
··· 1 + # OxCaml Mode Rendering Fixes and Mode Links — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix redundant jkind annotations and spurious return modes in odoc's OxCaml rendering, and add a `--mode-links` flag for linking mode/jkind names to external documentation. 6 + 7 + **Architecture:** Three independent changes to odoc: (1) change the `Poly` variant in `lang.ml` and `component.ml` to carry optional jkinds per variable, then fix the loader to only extract jkinds at binding sites; (2) suppress return modes on inner arrows in the loader; (3) add `--mode-links URI` flag that makes mode/jkind names into `<a>` tags in the HTML output. 8 + 9 + **Tech Stack:** OCaml, dune, OxCaml 5.2.0+ox (for testing), cram tests. 10 + 11 + --- 12 + 13 + ### Task 1: Change `Poly` variant to carry optional jkinds 14 + 15 + The `Poly` variant exists in two parallel type systems. Both need updating, 16 + along with all pattern-match sites. 17 + 18 + **Files:** 19 + - Modify: `odoc/src/model/lang.ml:487` 20 + - Modify: `odoc/src/xref2/component.ml:132` 21 + - Modify: `odoc/src/model_desc/lang_desc.ml:669` 22 + - Modify: `odoc/src/xref2/compile.ml:978` 23 + - Modify: `odoc/src/xref2/link.ml:447,1226` 24 + - Modify: `odoc/src/xref2/lang_of.ml:1048` 25 + - Modify: `odoc/src/xref2/expand_tools.ml:67` 26 + - Modify: `odoc/src/xref2/component.ml:1218,2358` 27 + - Modify: `odoc/src/xref2/subst.ml:178,628` 28 + 29 + **Step 1: Update the type definition in `lang.ml`** 30 + 31 + Change line 487 from: 32 + ```ocaml 33 + | Poly of string list * t 34 + ``` 35 + to: 36 + ```ocaml 37 + | Poly of (string * string option) list * t 38 + (** Universally quantified type variables with optional jkind 39 + annotation, e.g. [("a", Some "value_or_null")] for 40 + [('a : value_or_null). ...]. The jkind is [None] for the 41 + default [value] layout. *) 42 + ``` 43 + 44 + **Step 2: Update the mirror type in `component.ml`** 45 + 46 + Change line 132 from: 47 + ```ocaml 48 + | Poly of string list * t 49 + ``` 50 + to: 51 + ```ocaml 52 + | Poly of (string * string option) list * t 53 + ``` 54 + 55 + **Step 3: Update `lang_desc.ml` serialization** 56 + 57 + Change line 669 from: 58 + ```ocaml 59 + | Poly (x1, x2) -> C ("Poly", (x1, x2), Pair (List string, typeexpr_t)) 60 + ``` 61 + to: 62 + ```ocaml 63 + | Poly (x1, x2) -> 64 + C ("Poly", (x1, x2), 65 + Pair (List (Pair (string, Option string)), typeexpr_t)) 66 + ``` 67 + 68 + **Step 4: Update all pass-through pattern matches in `xref2/`** 69 + 70 + These files just destructure `Poly` and reconstruct it — they pass the 71 + variable list through unchanged. Update each to use the new tuple type: 72 + 73 + In `compile.ml:978`: 74 + ```ocaml 75 + | Poly (vars, t) -> Poly (vars, type_expression env parent t) 76 + ``` 77 + (Already uses a generic name — no change needed if it compiles.) 78 + 79 + In `link.ml:447`: 80 + ```ocaml 81 + | Poly (_, t) | Alias (t, _) -> internal_typ_exp t 82 + ``` 83 + (Wildcard — no change needed.) 84 + 85 + In `link.ml:1226`: 86 + ```ocaml 87 + | Poly (vars, t) -> Poly (vars, type_expression env parent visited t) 88 + ``` 89 + (Generic name — no change needed if the types align.) 90 + 91 + In `lang_of.ml:1048`: 92 + ```ocaml 93 + | Poly (vars, t) -> Poly (vars, type_expr map parent t) 94 + ``` 95 + (Generic name — no change needed.) 96 + 97 + In `expand_tools.ml:67`: 98 + ```ocaml 99 + | Poly (s, t) -> Poly (s, type_expr map t) 100 + ``` 101 + (Generic name — no change needed.) 102 + 103 + In `component.ml:1218`: 104 + ```ocaml 105 + | Poly (_vars, _t) -> Format.fprintf ppf "(poly)" 106 + ``` 107 + (Wildcard — no change needed.) 108 + 109 + In `component.ml:2358`: 110 + ```ocaml 111 + | Poly (s, ts) -> Poly (s, type_expression ident_map ts) 112 + ``` 113 + (Generic name — no change needed.) 114 + 115 + In `subst.ml:178`: 116 + ```ocaml 117 + | Poly (vars, ts) -> Poly (vars, substitute_vars vars ts) 118 + ``` 119 + (Note: `substitute_vars` takes the variable list — check if it needs 120 + updating. Read the function signature.) 121 + 122 + In `subst.ml:628`: 123 + ```ocaml 124 + | Poly (vars, ts) -> Poly (vars, type_expr s ts) 125 + ``` 126 + (Generic name — no change needed.) 127 + 128 + **Step 5: Check `subst.ml` `substitute_vars`** 129 + 130 + Read `subst.ml` around line 178 to check whether `substitute_vars` consumes 131 + the variable names from the `Poly` list. If it does (e.g. to skip 132 + substituting bound variables), update it to extract just the names: 133 + 134 + ```ocaml 135 + | Poly (vars, ts) -> 136 + let names = List.map fst vars in 137 + Poly (vars, substitute_vars names ts) 138 + ``` 139 + 140 + **Step 6: Build to verify type changes compile** 141 + 142 + ```bash 143 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src 2>&1 | head -30 144 + ``` 145 + 146 + Expected: clean build, or errors only in the loader/generator (which we 147 + haven't updated yet). Fix any remaining pattern-match errors. 148 + 149 + **Step 7: Also build with the default switch** 150 + 151 + ```bash 152 + opam exec -- dune build odoc/src 2>&1 | head -30 153 + ``` 154 + 155 + Expected: clean build (the non-OXCAML code paths should be unaffected). 156 + 157 + **Step 8: Commit** 158 + 159 + ```bash 160 + git add odoc/src/model/lang.ml odoc/src/xref2/component.ml \ 161 + odoc/src/model_desc/lang_desc.ml odoc/src/xref2/compile.ml \ 162 + odoc/src/xref2/link.ml odoc/src/xref2/lang_of.ml \ 163 + odoc/src/xref2/expand_tools.ml odoc/src/xref2/subst.ml 164 + git commit -m "odoc: change Poly variant to carry optional jkinds per variable 165 + 166 + Poly of string list * t --> Poly of (string * string option) list * t 167 + 168 + Each universally quantified variable can now carry an optional jkind 169 + annotation (e.g. value_or_null, float64). This enables showing the 170 + jkind at the binding site rather than repeating it at every use. 171 + 172 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 173 + ``` 174 + 175 + --- 176 + 177 + ### Task 2: Fix jkind extraction — binding site only 178 + 179 + Update the loader to extract jkinds at the `Tunivar` binding site (inside 180 + `Tpoly`) and suppress them at `Tvar` use sites. 181 + 182 + **Files:** 183 + - Modify: `odoc/src/loader/cmi.cppo.ml:614-617` (Tvar branch) 184 + - Modify: `odoc/src/loader/cmi.cppo.ml:671-676` (Tpoly branch) 185 + 186 + **Step 1: Suppress jkind on `Tvar` use sites** 187 + 188 + In `cmi.cppo.ml`, change lines 614-617 from: 189 + ```ocaml 190 + | Tvar { name; jkind } -> 191 + let nm = match name with Some n -> n | None -> name_of_type typ in 192 + if nm = "_" then Any 193 + else Var (nm, extract_jkind_of_tvar jkind) 194 + ``` 195 + to: 196 + ```ocaml 197 + | Tvar { name; _ } -> 198 + (* Tvar is a use site — don't annotate with jkind here. 199 + Jkinds are extracted at the Tunivar binding site in Tpoly, 200 + matching Printtyp's convention of stating the jkind once 201 + at the universal quantifier. *) 202 + let nm = match name with Some n -> n | None -> name_of_type typ in 203 + if nm = "_" then Any 204 + else Var (nm, None) 205 + ``` 206 + 207 + **Step 2: Extract jkinds in the `Tpoly` branch** 208 + 209 + Change lines 671-676 from: 210 + ```ocaml 211 + | Tpoly (typ, tyl) -> 212 + let tyl = List.map Compat.repr tyl in 213 + let vars = List.map name_of_type_repr tyl in 214 + let typ = read_type_expr env typ in 215 + remove_names tyl; 216 + Poly(vars, typ) 217 + ``` 218 + to: 219 + ```ocaml 220 + | Tpoly (typ, tyl) -> 221 + let tyl = List.map Compat.repr tyl in 222 + let vars = List.map (fun repr -> 223 + let name = name_of_type_repr repr in 224 + (* Extract jkind from the Tunivar binding site. 225 + This is the only place jkinds are recorded — use sites 226 + (Tvar) intentionally return None to avoid redundancy. *) 227 + let jkind = 228 + #if defined OXCAML 229 + match repr.desc with 230 + | Tunivar { jkind; _ } -> extract_jkind_of_tvar jkind 231 + | _ -> None 232 + #else 233 + None 234 + #endif 235 + in 236 + (name, jkind)) tyl 237 + in 238 + let typ = read_type_expr env typ in 239 + remove_names (List.map (fun r -> r) tyl); 240 + Poly(vars, typ) 241 + ``` 242 + 243 + Note: `remove_names` takes `repr list`. Check its signature — it may need 244 + the original `tyl` list, not the mapped result. Keep a separate binding 245 + if needed: 246 + 247 + ```ocaml 248 + | Tpoly (typ, tyl) -> 249 + let reprs = List.map Compat.repr tyl in 250 + let vars = List.map (fun repr -> 251 + let name = name_of_type_repr repr in 252 + let jkind = 253 + #if defined OXCAML 254 + match repr.desc with 255 + | Tunivar { jkind; _ } -> extract_jkind_of_tvar jkind 256 + | _ -> None 257 + #else 258 + None 259 + #endif 260 + in 261 + (name, jkind)) reprs 262 + in 263 + let typ = read_type_expr env typ in 264 + remove_names reprs; 265 + Poly(vars, typ) 266 + ``` 267 + 268 + **Step 3: Build with OxCaml switch** 269 + 270 + ```bash 271 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src/loader 2>&1 | head -20 272 + ``` 273 + 274 + Expected: clean build. 275 + 276 + **Step 4: Build with default switch** 277 + 278 + ```bash 279 + opam exec -- dune build odoc/src/loader 2>&1 | head -20 280 + ``` 281 + 282 + Expected: clean build (non-OXCAML path unchanged). 283 + 284 + **Step 5: Commit** 285 + 286 + ```bash 287 + git add odoc/src/loader/cmi.cppo.ml 288 + git commit -m "odoc loader: extract jkinds at Tpoly binding site only 289 + 290 + Tvar use sites now always return Var(name, None). Jkinds are extracted 291 + from Tunivar nodes inside the Tpoly branch, so the annotation appears 292 + once at the universal quantifier rather than at every occurrence. 293 + 294 + Fixes redundant ('a : value_or_null) annotations on ppx-derived types. 295 + 296 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 297 + ``` 298 + 299 + --- 300 + 301 + ### Task 3: Fix return mode elision on inner arrows 302 + 303 + Suppress return modes when the return type is itself a function, matching 304 + Printtyp's elision. 305 + 306 + **Files:** 307 + - Modify: `odoc/src/loader/cmi.cppo.ml:618-620` 308 + 309 + **Step 1: Add return mode elision** 310 + 311 + Change lines 618-620 from: 312 + ```ocaml 313 + | Tarrow((lbl, marg, mret), arg, res, _) -> 314 + let arg_modes = extract_arg_modes marg in 315 + let ret_modes = extract_arg_modes mret in 316 + ``` 317 + to: 318 + ```ocaml 319 + | Tarrow((lbl, marg, mret), arg, res, _) -> 320 + let arg_modes = extract_arg_modes marg in 321 + (* Suppress return modes when the return type is itself a function. 322 + A closure capturing a local argument is necessarily local, so 323 + the return mode is always implied. Showing it is redundant. 324 + This matches the elision logic in Printtyp.tree_of_modes. *) 325 + let ret_modes = match Compat.get_desc res with 326 + | Tarrow _ -> [] 327 + | _ -> extract_arg_modes mret 328 + in 329 + ``` 330 + 331 + **Step 2: Build with OxCaml switch** 332 + 333 + ```bash 334 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src/loader 2>&1 | head -20 335 + ``` 336 + 337 + Expected: clean build. 338 + 339 + **Step 3: Commit** 340 + 341 + ```bash 342 + git add odoc/src/loader/cmi.cppo.ml 343 + git commit -m "odoc loader: suppress return modes on inner arrow types 344 + 345 + When the return type of an arrow is itself a Tarrow, the return mode 346 + is implied by the argument mode (a closure capturing a local value is 347 + necessarily local). Eliding it matches Printtyp's behaviour. 348 + 349 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 350 + ``` 351 + 352 + --- 353 + 354 + ### Task 4: Update the document generator `Poly` rendering 355 + 356 + Update the renderer to show jkinds at the quantifier binding site. 357 + 358 + **Files:** 359 + - Modify: `odoc/src/document/generator.ml:521-522` 360 + 361 + **Step 1: Update the `Poly` rendering** 362 + 363 + Change lines 521-522 from: 364 + ```ocaml 365 + | Poly (polyvars, t) -> 366 + O.txt ("'" ^ String.concat ~sep:" '" polyvars ^ ". ") ++ type_expr t 367 + ``` 368 + to: 369 + ```ocaml 370 + | Poly (polyvars, t) -> 371 + let render_var (name, jkind) = match jkind with 372 + | None -> "'" ^ name 373 + | Some jk -> "('" ^ name ^ " : " ^ jk ^ ")" 374 + in 375 + O.txt (String.concat ~sep:" " (List.map render_var polyvars) ^ ". ") 376 + ++ type_expr t 377 + ``` 378 + 379 + This renders: 380 + - `'a.` when jkind is `None` (default `value` layout) 381 + - `('a : value_or_null).` when jkind is `Some "value_or_null"` 382 + 383 + **Step 2: Build** 384 + 385 + ```bash 386 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src/document 2>&1 | head -20 387 + ``` 388 + 389 + Expected: clean build. 390 + 391 + **Step 3: Commit** 392 + 393 + ```bash 394 + git add odoc/src/document/generator.ml 395 + git commit -m "odoc generator: render jkind at Poly quantifier binding site 396 + 397 + Shows ('a : value_or_null). instead of plain 'a. when the quantified 398 + variable has a non-default jkind. Jkind is shown once at the binding 399 + site, not at every use of the variable. 400 + 401 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 402 + ``` 403 + 404 + --- 405 + 406 + ### Task 5: Update the integration test 407 + 408 + The existing `oxcaml_modes.t` cram test needs updating to verify the fixes 409 + and cover the new jkind-at-quantifier rendering. 410 + 411 + **Files:** 412 + - Modify: `odoc/test/integration/oxcaml_modes.t/test_modes.mli` 413 + - Modify: `odoc/test/integration/oxcaml_modes.t/run.t` 414 + 415 + **Step 1: Add a `Poly` with jkind to the test `.mli`** 416 + 417 + Add at the end of `test_modes.mli`: 418 + ```ocaml 419 + (** {1 Polymorphic with jkind quantifier} *) 420 + 421 + type ('a : value_or_null) box 422 + 423 + val local_compare : ('a : value_or_null). ('a @ local -> 'a @ local -> int) -> 'a box @ local -> 'a box @ local -> int 424 + ``` 425 + 426 + **Step 2: Build and capture the actual output** 427 + 428 + ```bash 429 + cd /home/jons-agent/workspace/mono 430 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src 431 + OPAMSWITCH=5.2.0+ox opam exec -- bash -c ' 432 + cd /tmp/odoc-test-cram && rm -rf * 433 + ocamlc -bin-annot -c /home/jons-agent/workspace/mono/odoc/test/integration/oxcaml_modes.t/test_modes.mli 434 + /home/jons-agent/workspace/mono/_build/default/odoc/src/odoc/bin/main.exe compile --package test test_modes.cmti 435 + /home/jons-agent/workspace/mono/_build/default/odoc/src/odoc/bin/main.exe link test_modes.odoc 436 + /home/jons-agent/workspace/mono/_build/default/odoc/src/odoc/bin/main.exe html-generate test_modes.odocl -o html --indent 437 + cat html/test/Test_modes/index.html 438 + ' 439 + ``` 440 + 441 + **Step 3: Update `run.t` expected output** 442 + 443 + Update the cram test expectations to match the new rendering: 444 + - Jkind annotations should appear only at the `Poly` quantifier 445 + - No `@ local` on inner return types 446 + - Add new test cases for the `local_compare` function 447 + 448 + Verify jkind at quantifier: 449 + ``` 450 + $ grep 'value_or_null' html/test/Test_modes/index.html | head -3 451 + ``` 452 + 453 + Expected: jkind appears in the type parameter and the `Poly` quantifier 454 + only, not at every `'a` use site. 455 + 456 + Verify no spurious return modes on inner arrows: 457 + ``` 458 + $ # For local_compare, the inner ('a @ local -> int) should NOT have @ local after it 459 + $ grep -o '[^<]*' html/test/Test_modes/index.html | grep -c '@ local.*@ local.*@ local' 460 + ``` 461 + 462 + **Step 4: Run the cram test** 463 + 464 + ```bash 465 + OPAMSWITCH=5.2.0+ox opam exec -- dune test odoc/test/integration/oxcaml_modes.t 2>&1 466 + ``` 467 + 468 + If the test fails because expected output doesn't match, update `run.t` 469 + with the actual output using: 470 + ```bash 471 + OPAMSWITCH=5.2.0+ox opam exec -- dune promote odoc/test/integration/oxcaml_modes.t 472 + ``` 473 + 474 + Then review the promoted output to make sure it looks correct. 475 + 476 + **Step 5: Also run with default switch to make sure non-OxCaml tests pass** 477 + 478 + ```bash 479 + opam exec -- dune test 2>&1 | tail -20 480 + ``` 481 + 482 + **Step 6: Commit** 483 + 484 + ```bash 485 + git add odoc/test/integration/oxcaml_modes.t/ 486 + git commit -m "odoc test: update oxcaml_modes test for jkind and mode fixes 487 + 488 + Add test case with Poly quantifier carrying value_or_null jkind. 489 + Update expected output: jkind at binding site only, no spurious 490 + return modes on inner arrows. 491 + 492 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 493 + ``` 494 + 495 + --- 496 + 497 + ### Task 6: Add `--mode-links` to `Config.t` 498 + 499 + Add the mode links configuration field. 500 + 501 + **Files:** 502 + - Modify: `odoc/src/html/config.ml` 503 + - Modify: `odoc/src/html/config.mli` 504 + 505 + **Step 1: Add field to `config.ml`** 506 + 507 + Add `mode_links : string option;` to the record type (after `home_breadcrumb`): 508 + ```ocaml 509 + home_breadcrumb : string option; 510 + mode_links : string option; 511 + } 512 + ``` 513 + 514 + Add `?mode_links` parameter to `v` (before `unit ->`): 515 + ```ocaml 516 + ~remap ?home_breadcrumb ?mode_links () = 517 + ``` 518 + 519 + Add to record literal in `v`: 520 + ```ocaml 521 + mode_links; 522 + ``` 523 + 524 + Add accessor: 525 + ```ocaml 526 + let mode_links config = config.mode_links 527 + ``` 528 + 529 + **Step 2: Update `config.mli`** 530 + 531 + Add `?mode_links:string ->` to the `v` signature (before `unit ->`): 532 + ```ocaml 533 + ?home_breadcrumb:string -> 534 + ?mode_links:string -> 535 + unit -> 536 + t 537 + ``` 538 + 539 + Add accessor: 540 + ```ocaml 541 + val mode_links : t -> string option 542 + ``` 543 + 544 + **Step 3: Build** 545 + 546 + ```bash 547 + opam exec -- dune build odoc/src/html 2>&1 | head -10 548 + ``` 549 + 550 + Expected: clean build. 551 + 552 + **Step 4: Commit** 553 + 554 + ```bash 555 + git add odoc/src/html/config.ml odoc/src/html/config.mli 556 + git commit -m "odoc html: add mode_links field to Config.t 557 + 558 + Optional base URI for linking mode and jkind names to external 559 + documentation. When set, mode names become fragments appended to 560 + the URI, e.g. 'local' links to URI#local. 561 + 562 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 563 + ``` 564 + 565 + --- 566 + 567 + ### Task 7: Add `--mode-links` cmdliner argument 568 + 569 + Wire the new flag from the CLI to the config. 570 + 571 + **Files:** 572 + - Modify: `odoc/src/odoc/bin/main.ml:1313-1339` 573 + 574 + **Step 1: Add the argument definition** 575 + 576 + Add after the `home_breadcrumb` definition (around line 1308), before 577 + `extra_args`: 578 + 579 + ```ocaml 580 + let mode_links = 581 + let doc = 582 + "Base URI for mode and jkind documentation links. Mode and jkind \ 583 + names become the URI fragment, e.g. $(b,--mode-links \ 584 + https://example.com/modes) makes $(i,local) link to \ 585 + $(i,https://example.com/modes#local)." 586 + in 587 + Arg.(value & opt (some string) None & info [ "mode-links" ] ~docv:"URI" ~doc) 588 + in 589 + ``` 590 + 591 + **Step 2: Wire into `extra_args`** 592 + 593 + Add `mode_links` parameter to the `config` function (line 1314-1316): 594 + ```ocaml 595 + let config semantic_uris closed_details indent theme_uri support_uri 596 + search_uris extra_css flat as_json shell remap remap_file 597 + home_breadcrumb mode_links = 598 + ``` 599 + 600 + Pass it to `Config.v` (line 1330-1332): 601 + ```ocaml 602 + let html_config = 603 + Odoc_html.Config.v ~theme_uri ~support_uri ~search_uris ~extra_css 604 + ~semantic_uris ~indent ~flat ~open_details ~as_json ?shell ~remap 605 + ?home_breadcrumb ?mode_links () 606 + in 607 + ``` 608 + 609 + Add `$ mode_links` to the Term application (line 1337-1339): 610 + ```ocaml 611 + Term.( 612 + const config $ semantic_uris $ closed_details $ indent $ theme_uri 613 + $ support_uri $ search_uri $ extra_css $ flat $ as_json $ shell $ remap 614 + $ remap_file $ home_breadcrumb $ mode_links) 615 + ``` 616 + 617 + **Step 3: Build** 618 + 619 + ```bash 620 + opam exec -- dune build odoc/src/odoc 2>&1 | head -10 621 + ``` 622 + 623 + Expected: clean build. 624 + 625 + **Step 4: Verify the flag appears in help** 626 + 627 + ```bash 628 + opam exec -- dune exec odoc -- html-generate --help 2>&1 | grep -A2 mode-links 629 + ``` 630 + 631 + Expected: `--mode-links URI` appears in the help output. 632 + 633 + **Step 5: Commit** 634 + 635 + ```bash 636 + git add odoc/src/odoc/bin/main.ml 637 + git commit -m "odoc html-generate: add --mode-links URI argument 638 + 639 + Passes a base URI to the HTML renderer for linking mode and jkind 640 + names to external documentation. Fragment is the name as rendered. 641 + 642 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 643 + ``` 644 + 645 + --- 646 + 647 + ### Task 8: Render mode/jkind names as links in the document generator 648 + 649 + When `mode_links` is configured, emit each mode and jkind name as a linked 650 + element instead of plain text. 651 + 652 + **Files:** 653 + - Modify: `odoc/src/document/generator.ml:454-510` (mode rendering in arrows) 654 + - Modify: `odoc/src/document/generator.ml:521-522` (jkind rendering in Poly — already changed in Task 4) 655 + - Modify: `odoc/src/document/generator.ml:1024-1027` (modality rendering on values) 656 + 657 + Note: The document generator doesn't currently have access to the HTML 658 + config. Mode linking needs to work at the document level by emitting 659 + something the HTML renderer can turn into an `<a>` tag. Check how this 660 + is best done — either pass config through, or emit a special inline 661 + element. 662 + 663 + **Step 1: Investigate how to emit links from the document generator** 664 + 665 + Read how `Link.from_path` works in `generator.ml` (around line 111). The 666 + document generator creates `Inline.Link` elements with a `Target.t`. For 667 + external URLs, check if `Target.External` exists or if we need a different 668 + approach. 669 + 670 + Read `odoc/src/document/types.ml` to see the `Target` type. 671 + 672 + If there's no `External` target, consider adding a simple `External of string` 673 + case, or handle linking in the HTML generator instead (which already has 674 + config access). 675 + 676 + **Step 2: Implement mode linking** 677 + 678 + The exact approach depends on Step 1. Two likely paths: 679 + 680 + **Path A — Document-level linking:** If we can emit external links from the 681 + document generator, add a helper: 682 + 683 + ```ocaml 684 + let mode_txt ~config name = 685 + match Odoc_html.Config.mode_links config with 686 + | None -> O.txt name 687 + | Some base_uri -> 688 + let url = base_uri ^ "#" ^ name in 689 + (* emit as external link *) 690 + ... 691 + ``` 692 + 693 + **Path B — HTML-level linking:** If the document generator can't emit 694 + external links cleanly, keep mode names as plain `O.txt` at the document 695 + level, and do the link wrapping in the HTML renderer (`html/generator.ml`) 696 + where config is available. This means adding a `Tag (Some "mode", ...)` or 697 + similar marker that the HTML renderer recognises. 698 + 699 + Choose the simpler path. Document the decision in a code comment. 700 + 701 + **Step 3: Apply to all mode rendering sites** 702 + 703 + There are three places where mode/jkind names are emitted as text: 704 + 705 + 1. Arrow arg/ret modes (lines 454-510): `O.txt (String.concat ~sep:" " ms)` 706 + 2. Poly jkind (line 521, updated in Task 4): `O.txt jk` 707 + 3. Value modalities (lines 1024-1027): `O.txt (String.concat ~sep:" " ms)` 708 + 709 + Update all three to use the mode-linking helper. 710 + 711 + **Step 4: Build** 712 + 713 + ```bash 714 + opam exec -- dune build odoc/src 2>&1 | head -20 715 + ``` 716 + 717 + Expected: clean build. 718 + 719 + **Step 5: Commit** 720 + 721 + ```bash 722 + git add odoc/src/document/generator.ml odoc/src/html/generator.ml 723 + git commit -m "odoc: link mode and jkind names when --mode-links is set 724 + 725 + When mode_links is configured, mode names (local, portable, etc.) 726 + and jkind names (value_or_null, float64, etc.) are rendered as 727 + hyperlinks to URI#name. The @ and @@ keywords remain unlinked. 728 + 729 + Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>" 730 + ``` 731 + 732 + --- 733 + 734 + ### Task 9: End-to-end verification 735 + 736 + Build odoc with OxCaml, generate HTML for the test case, and verify 737 + all three fixes plus mode linking work correctly. 738 + 739 + **Step 1: Build odoc** 740 + 741 + ```bash 742 + OPAMSWITCH=5.2.0+ox opam exec -- dune build odoc/src/odoc/bin/main.exe 2>&1 | tail -5 743 + ``` 744 + 745 + **Step 2: Compile a test file with ppx-derived types** 746 + 747 + ```bash 748 + mkdir -p /tmp/odoc-verify 749 + cat > /tmp/odoc-verify/test.mli <<'EOF' 750 + (** End-to-end verification *) 751 + 752 + type ('a : value_or_null) t 753 + [@@deriving compare ~localize] 754 + EOF 755 + OPAMSWITCH=5.2.0+ox opam exec -- bash -c ' 756 + cd /tmp/odoc-verify 757 + ocamlfind ocamlc -bin-annot -package ppx_compare -c test.mli 758 + ' 759 + ``` 760 + 761 + **Step 3: Run odoc pipeline with `--mode-links`** 762 + 763 + ```bash 764 + ODOC=/home/jons-agent/workspace/mono/_build/default/odoc/src/odoc/bin/main.exe 765 + OPAMSWITCH=5.2.0+ox opam exec -- bash -c " 766 + cd /tmp/odoc-verify 767 + $ODOC compile --package test test.cmti 768 + $ODOC link test.odoc 769 + $ODOC html-generate test.odocl -o html --indent --mode-links https://oxcaml.org/modes 770 + cat html/test/Test/index.html 771 + " 772 + ``` 773 + 774 + **Step 4: Verify the output** 775 + 776 + Check for: 777 + 1. **Jkind at quantifier only:** `('a : value_or_null).` appears once, 778 + plain `'a` everywhere else 779 + 2. **No spurious return modes:** inner arrows don't have `@ local` on 780 + their return type 781 + 3. **Mode links:** mode names are wrapped in `<a class="mode-link" 782 + href="https://oxcaml.org/modes#local">local</a>` 783 + 4. **Jkind links:** `<a class="mode-link" 784 + href="https://oxcaml.org/modes#value_or_null">value_or_null</a>` 785 + 786 + ```bash 787 + # Verify jkind not repeated 788 + grep -o 'value_or_null' /tmp/odoc-verify/html/test/Test/index.html | wc -l 789 + # Expected: appears in type-t definition + once in Poly quantifier per 790 + # derived function (compare + compare__local), NOT at every 'a 791 + 792 + # Verify mode links exist 793 + grep 'mode-link.*local' /tmp/odoc-verify/html/test/Test/index.html | head -3 794 + 795 + # Verify no spurious return modes 796 + grep -c '@ local.*@ local.*@ local' /tmp/odoc-verify/html/test/Test/index.html 797 + # Expected: 0 or very few (only where semantically meaningful) 798 + ``` 799 + 800 + **Step 5: Run the full test suite** 801 + 802 + ```bash 803 + opam exec -- dune test 2>&1 | tail -20 804 + ``` 805 + 806 + Expected: all tests pass. 807 + 808 + **Step 6: Final commit (if any fixups needed)** 809 + 810 + --- 811 + 812 + ## Completion checklist 813 + 814 + - [ ] `Poly` variant carries `(string * string option) list` in both `lang.ml` and `component.ml` 815 + - [ ] `Tvar` branch returns `Var(name, None)` — no jkind at use sites 816 + - [ ] `Tpoly` branch extracts jkinds from `Tunivar` nodes 817 + - [ ] `Tarrow` branch suppresses `ret_modes` when return type is `Tarrow` 818 + - [ ] `Poly` renderer shows `('a : jkind).` when jkind present 819 + - [ ] `--mode-links URI` flag added to `odoc html-generate` 820 + - [ ] Mode/jkind names rendered as `<a>` tags when mode-links configured 821 + - [ ] Integration test updated and passing 822 + - [ ] Both OxCaml and default switch build cleanly