Files
Nim/tools/kochdocs.nim
Andrey Makarov 2620da9bf9 docgen: implement cross-document links (#20990)
* docgen: implement cross-document links

Fully implements https://github.com/nim-lang/RFCs/issues/125
Follow-up of: https://github.com/nim-lang/Nim/pull/18642 (for internal links)
and https://github.com/nim-lang/Nim/issues/20127.

Overview
--------

Explicit import-like directive is required, called `.. importdoc::`.
(the syntax is % RST, Markdown will use it for a while).

Then one can reference any symbols/headings/anchors, as if they
were in the local file (but they will be prefixed with a module name
or markup document in link text).
It's possible to reference anything from anywhere (any direction
in `.nim`/`.md`/`.rst` files).

See `doc/docgen.md` for full description.

Working is based on `.idx` files, hence one needs to generate
all `.idx` beforehand. A dedicated option `--index:only` is introduced
(and a separate stage for `--index:only` is added to `kochdocs.nim`).

Performance note
----------------

Full run for `./koch docs` now takes 185% of the time before this PR.
(After: 315 s, before: 170 s on my PC).
All the time seems to be spent on `--index:only` run, which takes
almost as much (85%) of normal doc run -- it seems that most time
is spent on file parsing, turning off HTML generation phase has not
helped much.
(One could avoid it by specifying list of files that can be referenced
and pre-processing only them. But it can become error-prone and I assume
that these linke will be **everywhere** in the repository anyway,
especially considering https://github.com/nim-lang/RFCs/issues/478.
So every `.nim`/`.md` file is processed for `.idx` first).

But that's all without significant part of repository converted to
cross-module auto links. To estimate impact I checked the time for
`doc`ing a few files (after all indexes have been generated), and
everywhere difference was **negligible**.
E.g. for `lib/std/private/osfiles.nim` that `importdoc`s large
`os.idx` and hence should have been a case with relatively large
performance impact, but:

* After: 0.59 s.
* Before: 0.59 s.

So Nim compiler works so slow that doc part basically does not matter :-)

Testing
-------

1) added `extlinks` test to `nimdoc/`
2) checked that `theindex.html` is still correct
2) fixed broken auto-links for modules that were derived from `os.nim`
   by adding appropriate ``importdoc``

Implementation note
-------------------

Parsing and formating of `.idx` entries is moved into a dedicated
`rstidx.nim` module from `rstgen.nim`.

`.idx` file format changed:

* fields are not escaped in most cases because we need original
  strings for referencing, not HTML ones
  (the exception is linkTitle for titles and headings).
  Escaping happens later -- on the stage of `rstgen` buildIndex, etc.
* all lines have fixed number of columns 6
* added discriminator tag as a first column,
  it always allows distinguish Nim/markup entries, titles/headings, etc.
  `rstgen` does not rely any more (in most cases) on ad-hoc logic
  to determine what type each entry is.
* there is now always a title entry added at the first line.
* add a line number as 6th column
* linkTitle (4th) column has a different format: before it was like
  `module: funcName()`, now it's `proc funcName()`.
  (This format is also propagated to `theindex.html` and search results,
  I kept it that way since I like it more though it's discussible.)
  This column is what used for Nim symbols resolution.
* also changed details on column format for headings and titles:
  "keyword" is original, "linkTitle" is HTML one

* fix paths on Windows + more clear code

* Update compiler/docgen.nim

Co-authored-by: Andreas Rumpf <rumpf_a@web.de>

* Handle .md and .nim paths uniformly in findRefFile

* handle titles better + more comments

* don't allow markup overwrite index title for .nim files

Co-authored-by: Andreas Rumpf <rumpf_a@web.de>
2023-01-04 15:19:01 -05:00

388 lines
14 KiB
Nim

## Part of 'koch' responsible for the documentation generation.
import std/[os, strutils, osproc, sets, pathnorm, sequtils]
import officialpackages
export exec
when defined(nimPreviewSlimSystem):
import std/assertions
# XXX: Remove this feature check once the csources supports it.
when defined(nimHasCastPragmaBlocks):
import std/pegs
from std/private/globs import nativeToUnixPath, walkDirRecFilter, PathEntry
import "../compiler/nimpaths"
const
gaCode* = " --doc.googleAnalytics:UA-48159761-1"
paCode* = " --doc.plausibleAnalytics:nim-lang.org"
# errormax: subsequent errors are probably consequences of 1st one; a simple
# bug could cause unlimited number of errors otherwise, hard to debug in CI.
docDefines = "-d:nimExperimentalLinenoiseExtra"
nimArgs = "--errormax:3 --hint:Conf:off --hint:Path:off --hint:Processing:off --hint:XDeclaredButNotUsed:off --warning:UnusedImport:off -d:boot --putenv:nimversion=$# $#" % [system.NimVersion, docDefines]
gitUrl = "https://github.com/nim-lang/Nim"
docHtmlOutput = "doc/html"
webUploadOutput = "web/upload"
var nimExe*: string
const allowList = ["jsbigints.nim", "jsheaders.nim", "jsformdata.nim", "jsfetch.nim", "jsutils.nim"]
template isJsOnly(file: string): bool =
file.isRelativeTo("lib/js") or
file.extractFilename in allowList
proc exe*(f: string): string =
result = addFileExt(f, ExeExt)
when defined(windows):
result = result.replace('/','\\')
proc findNimImpl*(): tuple[path: string, ok: bool] =
if nimExe.len > 0: return (nimExe, true)
let nim = "nim".exe
result.path = "bin" / nim
result.ok = true
if fileExists(result.path): return
for dir in split(getEnv("PATH"), PathSep):
result.path = dir / nim
if fileExists(result.path): return
# assume there is a symlink to the exe or something:
return (nim, false)
proc findNim*(): string = findNimImpl().path
template inFold*(desc, body) =
if existsEnv("GITHUB_ACTIONS"):
echo "::group::" & desc
elif existsEnv("TF_BUILD"):
echo "##[group]" & desc
body
if existsEnv("GITHUB_ACTIONS"):
echo "::endgroup::"
elif existsEnv("TF_BUILD"):
echo "##[endgroup]"
proc execFold*(desc, cmd: string, errorcode: int = QuitFailure, additionalPath = "") =
## Execute shell command. Add log folding for various CI services.
let desc = if desc.len == 0: cmd else: desc
inFold(desc):
exec(cmd, errorcode, additionalPath)
proc execCleanPath*(cmd: string,
additionalPath = ""; errorcode: int = QuitFailure) =
# simulate a poor man's virtual environment
let prevPath = getEnv("PATH")
when defined(windows):
let cleanPath = r"$1\system32;$1;$1\System32\Wbem" % getEnv"SYSTEMROOT"
else:
const cleanPath = r"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/X11/bin"
putEnv("PATH", cleanPath & PathSep & additionalPath)
echo(cmd)
if execShellCmd(cmd) != 0: quit("FAILURE", errorcode)
putEnv("PATH", prevPath)
proc nimexec*(cmd: string) =
# Consider using `nimCompile` instead
exec findNim().quoteShell() & " " & cmd
proc nimCompile*(input: string, outputDir = "bin", mode = "c", options = "") =
let output = outputDir / input.splitFile.name.exe
let cmd = findNim().quoteShell() & " " & mode & " -o:" & output & " " & options & " " & input
exec cmd
proc nimCompileFold*(desc, input: string, outputDir = "bin", mode = "c", options = "", outputName = "") =
let outputName2 = if outputName.len == 0: input.splitFile.name.exe else: outputName.exe
let output = outputDir / outputName2
let cmd = findNim().quoteShell() & " " & mode & " -o:" & output & " " & options & " " & input
execFold(desc, cmd)
proc getMd2html(): seq[string] =
for a in walkDirRecFilter("doc"):
let path = a.path
if a.kind == pcFile and path.splitFile.ext == ".md" and path.lastPathPart notin
["docs.md", "nimfix.md",
"docstyle.md" # docstyle.md shouldn't be converted to html separately;
# it's included in contributing.md.
]:
# maybe we should still show nimfix, could help reviving it
# `docs` is redundant with `overview`, might as well remove that file?
result.add path
doAssert "doc/manual/var_t_return.md".unixToNativePath in result # sanity check
const
mdPdfList = """
manual.md
lib.md
tut1.md
tut2.md
tut3.md
nimc.md
niminst.md
mm.md
""".splitWhitespace().mapIt("doc" / it)
withoutIndex = """
lib/wrappers/tinyc.nim
lib/wrappers/pcre.nim
lib/wrappers/openssl.nim
lib/posix/posix.nim
lib/posix/linux.nim
lib/posix/termios.nim
""".splitWhitespace()
# some of these are include files so shouldn't be docgen'd
ignoredModules = """
lib/pure/future.nim
lib/pure/collections/hashcommon.nim
lib/pure/collections/tableimpl.nim
lib/pure/collections/setimpl.nim
lib/pure/ioselects/ioselectors_kqueue.nim
lib/pure/ioselects/ioselectors_select.nim
lib/pure/ioselects/ioselectors_poll.nim
lib/pure/ioselects/ioselectors_epoll.nim
lib/posix/posix_macos_amd64.nim
lib/posix/posix_other.nim
lib/posix/posix_nintendoswitch.nim
lib/posix/posix_nintendoswitch_consts.nim
lib/posix/posix_linux_amd64.nim
lib/posix/posix_linux_amd64_consts.nim
lib/posix/posix_other_consts.nim
lib/posix/posix_freertos_consts.nim
lib/posix/posix_openbsd_amd64.nim
lib/posix/posix_haiku.nim
""".splitWhitespace()
officialPackagesList = """
pkgs/asyncftpclient/src/asyncftpclient.nim
pkgs/smtp/src/smtp.nim
pkgs/punycode/src/punycode.nim
pkgs/db_connector/src/db_connector/db_common.nim
pkgs/db_connector/src/db_connector/db_mysql.nim
pkgs/db_connector/src/db_connector/db_odbc.nim
pkgs/db_connector/src/db_connector/db_postgres.nim
pkgs/db_connector/src/db_connector/db_sqlite.nim
""".splitWhitespace()
officialPackagesListWithoutIndex = """
pkgs/db_connector/src/db_connector/mysql.nim
pkgs/db_connector/src/db_connector/sqlite3.nim
pkgs/db_connector/src/db_connector/postgres.nim
pkgs/db_connector/src/db_connector/odbcsql.nim
pkgs/db_connector/src/db_connector/private/dbutils.nim
""".splitWhitespace()
proc findName(name: string): string =
doAssert name[0..4] == "pkgs/"
var i = 5
while i < name.len:
if name[i] != '/':
inc i
result.add name[i]
else:
break
when (NimMajor, NimMinor) < (1, 1) or not declared(isRelativeTo):
proc isRelativeTo(path, base: string): bool =
let path = path.normalizedPath
let base = base.normalizedPath
let ret = relativePath(path, base)
result = path.len > 0 and not ret.startsWith ".."
proc getDocList(): seq[string] =
var docIgnore: HashSet[string]
for a in withoutIndex: docIgnore.incl a
for a in ignoredModules: docIgnore.incl a
# don't ignore these even though in lib/system (not include files)
const goodSystem = """
lib/system/nimscript.nim
lib/system/assertions.nim
lib/system/iterators.nim
lib/system/exceptions.nim
lib/system/dollars.nim
lib/system/ctypes.nim
""".splitWhitespace()
proc follow(a: PathEntry): bool =
result = a.path.lastPathPart notin ["nimcache", htmldocsDirname,
"includes", "deprecated", "genode"] and
not a.path.isRelativeTo("lib/fusion") # fusion was un-bundled but we need to keep this in case user has it installed
for entry in walkDirRecFilter("lib", follow = follow):
let a = entry.path
if entry.kind != pcFile or a.splitFile.ext != ".nim" or
(a.isRelativeTo("lib/system") and a.nativeToUnixPath notin goodSystem) or
a.nativeToUnixPath in docIgnore:
continue
result.add a
result.add normalizePath("nimsuggest/sexp.nim")
let doc = getDocList()
proc sexec(cmds: openArray[string]) =
## Serial queue wrapper around exec.
for cmd in cmds:
echo(cmd)
let (outp, exitCode) = osproc.execCmdEx(cmd)
if exitCode != 0: quit outp
proc mexec(cmds: openArray[string]) =
## Multiprocessor version of exec
let r = execProcesses(cmds, {poStdErrToStdOut, poParentStreams, poEchoCmd})
if r != 0:
echo "external program failed, retrying serial work queue for logs!"
sexec(cmds)
proc buildDocSamples(nimArgs, destPath: string) =
## Special case documentation sample proc.
##
## TODO: consider integrating into the existing generic documentation builders
## now that we have a single `doc` command.
exec(findNim().quoteShell() & " doc $# -o:$# $#" %
[nimArgs, destPath / "docgen_sample.html", "doc" / "docgen_sample.nim"])
proc buildDocPackages(nimArgs, destPath: string, indexOnly: bool) =
# compiler docs; later, other packages (perhaps tools, testament etc)
let nim = findNim().quoteShell()
# to avoid broken links to manual from compiler dir, but a multi-package
# structure could be supported later
proc docProject(outdir, options, mainproj: string) =
exec("$nim doc --project --outdir:$outdir $nimArgs --git.url:$gitUrl $index $options $mainproj" % [
"nim", nim,
"outdir", outdir,
"nimArgs", nimArgs,
"gitUrl", gitUrl,
"options", options,
"mainproj", mainproj,
"index", if indexOnly: "--index:only" else: ""
])
let extra = "-u:boot"
# xxx keep in sync with what's in $nim_prs_D/config/nimdoc.cfg, or, rather,
# start using nims instead of nimdoc.cfg
docProject(destPath/"compiler", extra, "compiler/index.nim")
proc buildDoc(nimArgs, destPath: string, indexOnly: bool) =
# call nim for the documentation:
let rst2html = getMd2html()
var
commands = newSeq[string](rst2html.len + len(doc) + withoutIndex.len +
officialPackagesList.len + officialPackagesListWithoutIndex.len)
i = 0
let nim = findNim().quoteShell()
let index = if indexOnly: "--index:only" else: ""
for d in items(rst2html):
commands[i] = nim & " md2html $# --git.url:$# -o:$# $# $#" %
[nimArgs, gitUrl,
destPath / changeFileExt(splitFile(d).name, "html"), index, d]
i.inc
for d in items(doc):
let extra = if isJsOnly(d): "--backend:js" else: ""
var nimArgs2 = nimArgs
if d.isRelativeTo("compiler"): doAssert false
commands[i] = nim & " doc $# $# --git.url:$# --outdir:$# $# $#" %
[extra, nimArgs2, gitUrl, destPath, index, d]
i.inc
for d in items(withoutIndex):
commands[i] = nim & " doc $# --git.url:$# -o:$# $#" %
[nimArgs, gitUrl,
destPath / changeFileExt(splitFile(d).name, "html"), d]
i.inc
for d in items(officialPackagesList):
var nimArgs2 = nimArgs
if d.isRelativeTo("compiler"): doAssert false
commands[i] = nim & " doc $# --outdir:$# --index:on $#" %
[nimArgs2, destPath, d]
i.inc
for d in items(officialPackagesListWithoutIndex):
commands[i] = nim & " doc $# -o:$# $#" %
[nimArgs,
destPath / changeFileExt(splitFile(d).name, "html"), d]
i.inc
mexec(commands)
proc nim2pdf(src: string, dst: string, nimArgs: string) =
# xxx expose as a `nim` command or in some other reusable way.
let outDir = "build" / "xelatextmp" # xxx factor pending https://github.com/timotheecour/Nim/issues/616
# note: this will generate temporary files in gitignored `outDir`: aux toc log out tex
exec("$# md2tex $# --outdir:$# $#" % [findNim().quoteShell(), nimArgs, outDir.quoteShell, src.quoteShell])
let texFile = outDir / src.lastPathPart.changeFileExt("tex")
for i in 0..<3: # call LaTeX three times to get cross references right:
let xelatexLog = outDir / "xelatex.log"
# `>` should work on windows, if not, we can use `execCmdEx`
let cmd = "xelatex -interaction=nonstopmode -output-directory=$# $# > $#" % [outDir.quoteShell, texFile.quoteShell, xelatexLog.quoteShell]
exec(cmd) # on error, user can inspect `xelatexLog`
if i == 1: # build .ind file
var texFileBase = texFile
texFileBase.removeSuffix(".tex")
let cmd = "makeindex $# > $#" % [
texFileBase.quoteShell, xelatexLog.quoteShell]
exec(cmd)
moveFile(texFile.changeFileExt("pdf"), dst)
proc buildPdfDoc*(nimArgs, destPath: string) =
var pdfList: seq[string]
createDir(destPath)
if os.execShellCmd("xelatex -version") != 0:
doAssert false, "xelatex not found" # or, raise an exception
else:
for src in items(mdPdfList):
let dst = destPath / src.lastPathPart.changeFileExt("pdf")
pdfList.add dst
nim2pdf(src, dst, nimArgs)
echo "\nOutput PDF files: \n ", pdfList.join(" ") # because `nim2pdf` is a bit verbose
proc buildJS(): string =
let nim = findNim()
exec("$# js -d:release --out:$# tools/nimblepkglist.nim" %
[nim.quoteShell(), webUploadOutput / "nimblepkglist.js"])
# xxx deadcode? and why is it only for webUploadOutput, not for local docs?
result = getDocHacksJs(nimr = getCurrentDir(), nim)
proc buildDocsDir*(args: string, dir: string) =
let args = nimArgs & " " & args
let docHackJsSource = buildJS()
gitClonePackages(@["asyncftpclient", "punycode", "smtp", "db_connector"])
createDir(dir)
buildDocSamples(args, dir)
# generate `.idx` files and top-level `theindex.html`:
buildDoc(args, dir, indexOnly=true) # bottleneck
let nim = findNim().quoteShell()
exec(nim & " buildIndex -o:$1/theindex.html $1" % [dir])
# caveat: this works so long it's called before `buildDocPackages` which
# populates `compiler/` with unrelated idx files that shouldn't be in index,
# so should work in CI but you may need to remove your generated html files
# locally after calling `./koch docs`. The clean fix would be for `idx` files
# to be transient with `--project` (eg all in memory).
buildDocPackages(args, dir, indexOnly=true)
# generate HTML and package-level `theindex.html`:
buildDoc(args, dir, indexOnly=false) # bottleneck
buildDocPackages(args, dir, indexOnly=false)
copyFile(dir / "overview.html", dir / "index.html")
copyFile(docHackJsSource, dir / docHackJsSource.lastPathPart)
proc buildDocs*(args: string, localOnly = false, localOutDir = "") =
let localOutDir =
if localOutDir.len == 0:
docHtmlOutput
else:
localOutDir
var args = args
if not localOnly:
buildDocsDir(args, webUploadOutput / NimVersion)
# XXX: Remove this feature check once the csources supports it.
when defined(nimHasCastPragmaBlocks):
let gaFilter = peg"@( y'--doc.googleAnalytics:' @(\s / $) )"
args = args.replace(gaFilter)
buildDocsDir(args, localOutDir)