Odoc docs
odd doc DESIGN_DOC.md
14 kB · 272 lines
Markdown
at main

odd — doc: print the docs for a reference #

A CLI subcommand that takes an odoc reference and prints the docstring of the item it names, as Markdown:

$ odd doc Odoc_model.Paths.Identifier

The job is essentially the reference-resolution half of odoc link, run standalone against every .odoc file in the switch, followed by rendering the resolved item's doc comment to Markdown. odoc already does almost all of this in src/odoc/url.ml (the odoc html-url command), which resolves a reference to an identifier; we extend that path to fetch and render the full docstring rather than compute a URL.

odoc link (src/odoc/odoc_link.ml) loads an .odoc, builds an Odoc_xref2.Env.t from a Resolver.t, and calls into Odoc_xref2.Link. Link in turn uses Odoc_xref2.Ref_tools.resolve_reference for every {!...} it encounters. The environment is what gives the resolver access to other units: Resolver.create scans a list of -I directories into an Accessible_paths table (capitalised module name → .odoc file), and Resolver.build_env_for_reference wraps that into an Env.t whose lookup_unit loads units by name on demand. This is the most permissive env builder (important_digests = false, no current unit), which is exactly right for resolving an arbitrary reference from the command line — see url.ml, which already uses it.

So our resolution code is, almost verbatim, Url.resolve (but with package roots, see below — Url.resolve passes ~roots:None):

let resolver =
  Resolver.create ~important_digests:false ~directories
    ~open_modules:["Stdlib"] ~roots
in
let reference =
  Odoc_model.Semantics.parse_reference s            (* string -> Reference.t *)
  |> Odoc_model.Error.handle_errors_and_warnings ~warnings_options
in
let env = Resolver.build_env_for_reference resolver in
Odoc_xref2.Ref_tools.resolve_reference env reference
  |> Odoc_model.Error.raise_warnings
(* -> (Reference.Resolved.t * Comment.paragraph option, _) result *)

Reference.Resolved.identifier resolved then yields the canonical Identifier.t of the target (the same step url.ml feeds to Document.Url.from_identifier).

~open_modules:["Stdlib"] makes resolution behave as if open Stdlib, so a bare List or print_endline resolves — mirroring the compiler's (and the driver's link-time) default environment. Env.open_units looks each name up through the resolver and opens its signature; a module not documented in the switch is silently skipped.

Note the second element of the result is only a synopsis paragraph (the first paragraph, used when expanding {!ref} inline). We want the whole doc comment, so we ignore it and fetch the docs separately.

Building directories — always the whole switch #

Unlike odoc html-url, which is handed an explicit -I set, doc always searches across every package installed in the switch: the include set is derived, not configured.

Resolver.create's Accessible_paths looks up units by capitalised base name, scanning the directories it is given for *.odoc files. In a switch the driver writes these under $OPAM_SWITCH_PREFIX/odoc/<pkg>/<lib>/<Module>.odoc (verified on the dev switch: e.g. odoc/astring/astring/astring.odoc). (This is the odd-owned tree, separate from opam's own <prefix>/doc.) So:

  • discover the switch prefix exactly as the existing commands do (Odd.Switch.detect, --prefix);
  • recursively collect every directory under <prefix>/odoc that contains a .odoc file, and pass them all as directories (Refs.scan).

Only top-level unit files need to be reachable by name; sub-modules (.Paths, .Identifier) are resolved by walking into the parent unit's signature in memory, not from files. Cross-package module-name clashes are handled by odoc already (it warns and picks the first match).

Package-qualified references — -L and -P #

The flat directories set (the -I equivalent) is enough for ordinary dotted references — a `Name query always goes through the by-name Accessible_paths, regardless of roots. But package-qualified path references ({!/pkg/Module.value}, {!/pkg/page}) resolve through named roots, which ~roots:None doesn't provide. So doc also reconstructs the -L/-P roots the driver links with (landing_pages.ml, odoc_unit.ml):

  • -P <pkg>:<prefix>/odoc/<pkg> (page roots) — every top-level dir under odoc/;
  • -L <lib>:<prefix>/odoc/<pkg>/<lib> (lib roots) — their immediate subdirectories.

These are passed via Resolver.create's ~roots, with current_lib / current_package left None (there is no "current" unit, so relative and current-package path references — ./x, //x — don't apply; absolute /pkg/… ones do). The named-root dirs are a subset of directories, so this adds the path-reference capability without changing ordinary name resolution. Building the roots is cheap: Named_roots scans each lazily, only on access.

We resolve against .odoc (compiled) files, not .odocl — Accessible_paths only loads the .odoc extension, and compiled units already carry their docstrings. (References inside a docstring are unresolved in .odoc; that only matters if we want to turn doc-comment cross-references into links — see Open questions.)

From resolved identifier to the owning unit #

This is the one piece odoc link/url.ml doesn't already hand us — and the chosen approach is to load the target unit's linked .odocl and walk its Lang tree to the item (an env lookup_by_id-based shortcut was considered and rejected: lookup_by_id only finds ids already registered in env.ids, which is not guaranteed for an arbitrary deep reference after resolution).

The resolved identifier names a root compilation unit (Odoc_model) and a path of sub-modules down to the item. So:

  1. Identifier.fullname id gives the dotted path; its head is the root unit.
  2. Load that unit's .odocl and walk cu.content's Lang.Signature, recursing through module / module-type expansions and include expansions, comparing each item's identifier to the target with Identifier.equal. search_* in lib/doc.ml is that walk; expansions are reached via the simple_expansion/p_expansion/w_expansion/… fields, always populated in a linked file.
  3. Return the matched item's doc : Comment.docs. Because it comes from the linked file, cross-references inside the comment are already resolved, so they render as proper links rather than bare text.

Disambiguating the unit (the subtle bit). A switch routinely contains the same module name in several packages — e.g. both ocaml-compiler and ocamlfind ship a Stdlib.odocl. Looking the unit up by name alone can load a different Stdlib than the one the reference resolved into, and then Identifier.equal never matches. odoc identifiers are keyed by a dotted string (ikey) that runs from the leaf up through the root module to its container page (the package): e.g. t_result.r_Stdlib.p_stdlib.p_ocamlfind. The owning unit is therefore the candidate whose own ikey is a suffix of the target's. Refs.owns uses exactly this test to pick the correct .odocl among same-named candidates, keeping the loaded unit consistent with the resolved identifier.

Module top-comments. A module/module-type declaration is often itself undocumented, the prose living in the top-comment of its signature (the (** … *) just inside sig). When the target is a module, doc prefers the attached doc and falls back to the expansion signature's doc. (A bare section heading such as {2 …} is not a top-comment — it is a floating comment item — so a module whose signature opens with only a heading correctly reports "no documentation found".)

Rendering to Markdown — page vs. leaf #

What we render depends on what the reference points at, discriminated by the loaded file's content and Odoc_document.Url.from_identifier ~stop_before:false id:

  • a doc (mld) page (Page_content, URL kind `Page/`LeafPage) — render the whole page via Renderer.document_of_page;
  • a module-like item with an empty anchor (module, module type, class, or the unit itself) — render its whole page (below);
  • anything with a non-empty anchor is an item living inside a page (a value, type, exception, …) — render its declaration and doc comment.

The owning file is found uniformly for all of these: index_key maps the identifier to the .odocl basename (a unit's <RootModule>, a page's page-<name>), candidates are loaded, and the one whose root-identifier key is a suffix of the target's is kept (owns) — disambiguating both same-named units and the many identically-named page-index.odocl files across packages.

Module-like (empty anchor): render the whole page. Just printing the top-comment throws away the body, so instead we render the entire module the way odoc markdown-generate would:

let doc   = Odoc_document.Renderer.document_of_compilation_unit ~syntax:OCaml cu in
let pages = Odoc_markdown.Generator.render ~config doc in
(* pages is a tree: the unit's own page plus a child page per nested
   module/class. Pick the one whose Url.Path matches the target identifier
   and render its [content] formatter to a string. *)

This yields a heading, the module's doc, and each member's signature line with its synopsis — e.g. the shape of odoc's own test/generators/markdown/Toplevel_comments.md. Rendering the whole owning unit to reach a nested module's child page is a little wasteful for a big unit like Stdlib, but simple and correct; optimise later if it bites.

Leaf (non-empty anchor): render the declaration and its doc comment. The doc comment alone answers "what does it do" but not "what is it" — odd doc val:Cmdliner.Arg.info should show the val info : ... line the way odd search shows its right-hand side. The declaration lives in the document IR, not in the model's comment, so we build the same document as for a module page and pick the item anchored at the target URL:

let doc = Odoc_document.Renderer.document_of_compilation_unit ~syntax:OCaml cu in
(* Walk Page.preamble @ Page.items, descending into Include contents and the
   Subpages that nested modules become, for the Item whose anchor is the
   target's. Anchors on both sides are minted by Url.from_identifier, so they
   match exactly. *)

Constructors, fields and record labels are not items: they are anchored lines (DocumentedSrc.Documented/Nested) inside their type's declaration, so a match there is rewrapped as a standalone Item.Declaration before rendering. If nothing is anchored at the URL (a section label, say), we fall back to the old behaviour — the doc comment alone, via Odoc_document.Comment.standalone.

Either way the item list goes through the same renderer; Renderer.to_string takes a single block, so it is wrapped in Renderer.Block.Blocks:

let blocks =
  Odoc_markdown.Generator.items ~config ~resolve:(Odoc_markdown.Link.Base "") items
in
Odoc_markdown.Renderer.to_string (Odoc_markdown.Renderer.Block.Blocks blocks)

Base "" is fine: we are not producing a browsable tree, so cross-reference links render as relative-ish paths/text, acceptable for terminal output.

Tool shape (as built) #

A user-facing subcommand alongside search, rebuild, etc. in bin/main.ml, backed by lib/doc.ml (mirroring how lib/search.ml backs search):

odd doc [--prefix DIR] REFERENCE
  • REFERENCE is an odoc reference string (Odoc_model.Paths.Identifier, Stdlib.List.map, optionally tagged like val:/type:), parsed with Odoc_model.Semantics.parse_reference.
  • Exit codes: 0 on success; 1 (the shared exit_doc_failure) on a parse, resolution, load, or no-docs failure, with the message on stderr; cmdliner's CLI-error code only for switch-detection problems.
  • Resolution failures surface odoc's own Errors.Tools_error.pp_reference_lookup_error, as url.ml does.
  • The resolver emits unavoidable "ambiguous lookup" notices straight to stderr when a name occurs in several packages; doc follows the resolved identifier (so the choice is correct) and suppresses that stderr around the resolve call (with_suppressed_stderr) to keep output clean. Errors are returned as values, so none are lost.

Dependencies #

lib/dune gains the odoc internal libraries odoc.model, odoc.xref2, odoc.odoc (for Resolver/Semantics), odoc.document, odoc.markdown. These must be the same odoc that produced the switch's .odoc/.odocl (the formats are version-coupled). In this repo's dev switch odoc is pinned to the driver's source, so they match; in general odd should be built against the switch's odoc.

Open questions / future work #

  • Choosing among multiple matches. When a reference's root name matches more than one file across the switch (Refs.root_candidates), doc reports the ambiguity and lists the package-qualified alternatives rather than rendering whichever odoc picked first. (This fires only when resolution succeeds but is ambiguous; a dotted reference whose member is missing from the arbitrarily-picked root still fails to resolve rather than reporting the ambiguity. A --package filter could still help disambiguate deliberately.)
  • Leaf signatures. Module-like references render the full page, but a leaf reference (a val/type/…) still prints only its doc comment, not the val foo : … / type t = … signature line above it. Showing that line would mean locating the item's rendered entry within its parent page (or rendering the single item) — a reasonable follow-up.
  • Cross-reference link targets. With Base "" the in-comment links are not meaningfully clickable from a terminal; a future mode could rewrite them to odd doc invocations or to the switch's HTML.