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.
How odoc link resolves references (the part we reuse) #
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>/odocthat contains a.odocfile, and pass them all asdirectories(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 underodoc/;-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:
Identifier.fullname idgives the dotted path; its head is the root unit.- Load that unit's
.odocland walkcu.content'sLang.Signature, recursing through module / module-type expansions andincludeexpansions, comparing each item's identifier to the target withIdentifier.equal.search_*inlib/doc.mlis that walk; expansions are reached via thesimple_expansion/p_expansion/w_expansion/… fields, always populated in a linked file. - 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 viaRenderer.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
REFERENCEis an odoc reference string (Odoc_model.Paths.Identifier,Stdlib.List.map, optionally tagged likeval:/type:), parsed withOdoc_model.Semantics.parse_reference.- Exit codes:
0on success;1(the sharedexit_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, asurl.mldoes. - The resolver emits unavoidable "ambiguous lookup" notices straight to stderr
when a name occurs in several packages;
docfollows 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),docreports 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--packagefilter 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 theval 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 toodd docinvocations or to the switch's HTML.