/
docchecks.jl
383 lines (347 loc) · 14.3 KB
/
docchecks.jl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# Provides the [`missingdocs`](@ref), [`footnotes`](@ref) and [`linkcheck`](@ref) functions
# for checking docs.
# Missing docstrings.
# -------------------
"""
$(SIGNATURES)
Checks that a [`Document`](@ref) contains all available docstrings that are
defined in the `modules` keyword passed to [`makedocs`](@ref).
Prints out the name of each object that has not had its docs spliced into the document.
Returns the number of missing bindings to allow for automated testing of documentation.
"""
function missingdocs(doc::Document)
doc.user.checkdocs === :none && return 0
bindings = missingbindings(doc)
n = reduce(+, map(length, values(bindings)), init=0)
if n > 0
b = IOBuffer()
println(b, "$n docstring$(n ≡ 1 ? "" : "s") not included in the manual:\n")
for (binding, signatures) in bindings
for sig in signatures
println(b, " $binding", sig ≡ Union{} ? "" : " :: $sig")
end
end
println(b)
print(b, """
These are docstrings in the checked modules (configured with the modules keyword)
that are not included in canonical @docs or @autodocs blocks.
""")
@docerror(doc, :missing_docs, String(take!(b)))
end
return n
end
function missingbindings(doc::Document)
@debug "checking for missing docstrings."
bindings = allbindings(doc.user.checkdocs, doc.blueprint.modules)
for object in keys(doc.internal.objects)
if !is_canonical(object)
continue
end
# The module references in docs blocks can yield a binding like
# Docs.Binding(Mod, :SubMod) for a module SubMod, a submodule of Mod. However, the
# module bindings that come from Docs.meta() always appear to be of the form
# Docs.Binding(Mod.SubMod, :SubMod) (since Julia 0.7). We therefore "normalize"
# module bindings before we search in the list returned by allbindings().
binding = if DocSystem.defined(object.binding) && !DocSystem.iskeyword(object.binding)
m = DocSystem.resolve(object.binding)
isa(m, Module) && nameof(object.binding.mod) != object.binding.var ?
Docs.Binding(m, nameof(m)) : object.binding
else
object.binding
end
if haskey(bindings, binding)
signatures = bindings[binding]
if object.signature ≡ Union{} || length(signatures) ≡ 1
delete!(bindings, binding)
elseif object.signature in signatures
delete!(signatures, object.signature)
end
end
end
return bindings
end
function allbindings(checkdocs::Symbol, mods)
out = Dict{Binding, Set{Type}}()
for m in mods
allbindings(checkdocs, m, out)
end
out
end
function allbindings(checkdocs::Symbol, mod::Module, out = Dict{Binding, Set{Type}}())
for (binding, doc) in meta(mod)
# The keys of the docs meta dictionary should always be Docs.Binding objects in
# practice. However, the key type is Any, so it is theoretically possible that
# some non-binding metadata gets added to the dict. So on the off-chance that has
# happened, we simply ignore those entries.
isa(binding, Docs.Binding) || continue
# We only consider a name exported only if it actually exists in the module, either
# by virtue of being defined there, or if it has been brought into the scope with
# import/using.
name = nameof(binding)
isexported = (binding == Binding(mod, name)) && Base.isexported(mod, name)
if checkdocs === :all || (isexported && checkdocs === :exports)
out[binding] = Set(sigs(doc))
end
end
out
end
meta(m) = Docs.meta(m)
nameof(b::Base.Docs.Binding) = b.var
nameof(x) = Base.nameof(x)
sigs(x::Base.Docs.MultiDoc) = x.order
sigs(::Any) = Type[Union{}]
# Footnote checks.
# ----------------
"""
$(SIGNATURES)
Checks footnote links in a [`Document`](@ref).
"""
function footnotes(doc::Document)
@debug "checking footnote links."
# A mapping of footnote ids to a tuple counter of how many footnote references and
# footnote bodies have been found.
#
# For all ids the final result should be `(N, 1)` where `N > 1`, i.e. one or more
# footnote references and a single footnote body.
footnotes = Dict{Page, Dict{String, Tuple{Int, Int}}}()
for (src, page) in doc.blueprint.pages
orphans = Dict{String, Tuple{Int, Int}}()
for node in AbstractTrees.PreOrderDFS(page.mdast)
footnote(node.element, orphans)
end
footnotes[page] = orphans
end
for (page, orphans) in footnotes
for (id, (ids, bodies)) in orphans
# Multiple footnote bodies.
if bodies > 1
@docerror(doc, :footnote, "footnote '$id' has $bodies bodies in $(locrepr(page.source)).")
end
# No footnote references for an id.
if ids === 0
@docerror(doc, :footnote, "unused footnote named '$id' in $(locrepr(page.source)).")
end
# No footnote bodies for an id.
if bodies === 0
@docerror(doc, :footnote, "no footnotes found for '$id' in $(locrepr(page.source)).")
end
end
end
end
function footnote(fn::MarkdownAST.FootnoteLink, orphans::Dict)
ids, bodies = get(orphans, fn.id, (0, 0))
# Footnote references: syntax `[^1]`.
orphans[fn.id] = (ids + 1, bodies)
end
function footnote(fn::MarkdownAST.FootnoteDefinition, orphans::Dict)
ids, bodies = get(orphans, fn.id, (0, 0))
# Footnote body: syntax `[^1]:`.
orphans[fn.id] = (ids, bodies + 1)
end
footnote(other, orphans::Dict) = true
# Link Checks.
# ------------
hascurl() = (try; success(`curl --version`); catch err; false; end)
"""
$(SIGNATURES)
Checks external links using curl.
"""
function linkcheck(doc::Document)
if doc.user.linkcheck
if hascurl()
for (src, page) in doc.blueprint.pages
linkcheck(page.mdast, doc)
end
else
@docerror(doc, :linkcheck, "linkcheck requires `curl`.")
end
end
return nothing
end
function linkcheck(mdast::MarkdownAST.Node, doc::Document)
for node in AbstractTrees.PreOrderDFS(mdast)
linkcheck(node, node.element, doc)
end
end
function linkcheck(node::MarkdownAST.Node, element::MarkdownAST.AbstractElement, doc::Document)
# The linkcheck is only active for specific `element` types
# (`MarkdownAST.Link`, most importantly), which are defined below as more
# specific methods
return nothing
end
function linkcheck(node::MarkdownAST.Node, link::MarkdownAST.Link, doc::Document; method::Symbol=:HEAD)
# first, make sure we're not supposed to ignore this link
for r in doc.user.linkcheck_ignore
if linkcheck_ismatch(r, link.destination)
@debug "linkcheck '$(link.destination)': ignored."
return
end
end
if !haskey(doc.internal.locallinks, link)
timeout = doc.user.linkcheck_timeout
null_file = @static Sys.iswindows() ? "nul" : "/dev/null"
# In some cases, web servers (e.g. docs.github.com as of 2022) will reject requests
# that declare a non-browser user agent (curl specifically passes 'curl/X.Y'). In
# case of docs.github.com, the server returns a 403 with a page saying "The request
# is blocked". However, spoofing a realistic browser User-Agent string is enough to
# get around this, and so here we simply pass the example Chrome UA string from the
# Mozilla developer docs, but only is it's a HTTP(S) request.
#
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent#chrome_ua_string
fakebrowser = startswith(uppercase(link.destination), "HTTP") ? [
"--user-agent",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"-H",
"accept-encoding: gzip, deflate, br",
] : ""
cmd = `curl $(method === :HEAD ? "-sI" : "-s") --proto =http,https,ftp,ftps $(fakebrowser) $(link.destination) --max-time $timeout -o $null_file --write-out "%{http_code} %{url_effective} %{redirect_url}"`
local result
try
# interpolating into backticks escapes spaces so constructing a Cmd is necessary
result = read(cmd, String)
catch err
@docerror(doc, :linkcheck, "$cmd failed:", exception = err)
return false
end
STATUS_REGEX = r"^(\d+) (\w+)://(?:\S+) (\S+)?$"m
matched = match(STATUS_REGEX, result)
if matched !== nothing
status, scheme, location = matched.captures
status = parse(Int, status)
scheme = uppercase(scheme)
protocol = startswith(scheme, "HTTP") ? :HTTP :
startswith(scheme, "FTP") ? :FTP : :UNKNOWN
if (protocol === :HTTP && (status < 300 || status == 302)) ||
(protocol === :FTP && (200 <= status < 300 || status == 350))
if location !== nothing
@debug "linkcheck '$(link.destination)' status: $(status), redirects to '$(location)'"
else
@debug "linkcheck '$(link.destination)' status: $(status)."
end
elseif protocol === :HTTP && status < 400
if location !== nothing
@warn "linkcheck '$(link.destination)' status: $(status), redirects to '$(location)'"
else
@warn "linkcheck '$(link.destination)' status: $(status)."
end
elseif protocol === :HTTP && status == 405 && method === :HEAD
# when a server doesn't support HEAD requests, fallback to GET
@debug "linkcheck '$(link.destination)' status: $(status), retrying without `-I`"
return linkcheck(node, link, doc; method=:GET)
else
@docerror(doc, :linkcheck, "linkcheck '$(link.destination)' status: $(status).")
end
else
@docerror(doc, :linkcheck, "invalid result returned by $cmd:", result)
end
end
return false
end
function linkcheck(node::MarkdownAST.Node, docs_node::Documenter.DocsNode, doc::Document)
for mdast in docs_node.mdasts
linkcheck(mdast, doc)
end
end
linkcheck_ismatch(r::String, url) = (url == r)
linkcheck_ismatch(r::Regex, url) = occursin(r, url)
# Automatic Pkg.add() GitHub remote check
# ---------------------------------------
function gh_get_json(path)
io = IOBuffer()
url = "https://api.github.com$(path)"
@debug "request: GET $url"
resp = Downloads.request(
url,
output=io,
headers=Dict(
"Accept" => "application/vnd.github.v3+json",
"X-GitHub-Api-Version" => "2022-11-28"
)
)
return resp.status, JSON.parse(String(take!(io)))
end
function tag(repo, tag_ref)
status, result = gh_get_json("/repos/$(repo)/git/ref/tags/$(tag_ref)")
if status == 404
return nothing
elseif status != 200
error("Unexpected error code $(status) '$(repo)' while getting tag '$(tag_ref)'.")
end
if result["object"]["type"] == "tag"
status, result = gh_get_json("/repos/$(repo)/git/tags/$(result["object"]["sha"])")
if status == 404
return nothing
elseif status != 200
error("Unexpected error code $(status) '$(repo)' while getting tag '$(tag_ref)'.")
end
end
return result
end
function gitcommit(repo, commit_tag)
status, result = gh_get_json("/repos/$(repo)/git/commits/$(commit_tag)")
if status != 200
error("Unexpected error code $(status) '$(repo)' while getting commit '$(commit_tag)'.")
end
return result
end
GITHUB_ERROR_ADVICE = (
"This means automatically finding the source URL link for this package failed. " *
"Please add the source URL link manually to the `remotes` field " *
"in `makedocs` or install the package using `Pkg.develop()``."
)
function githubcheck(doc::Document)
if !doc.user.linkcheck || (doc.user.remotes === nothing)
return
end
# When we add GitHub links based on packages which have been added with
# Pkg.add(), we don't have much git information, so we simply use a guessed
# tag based on the version `v$VERSION`, as this tag is added by the popular
# TagBot action.
#
# This check uses the GitHub API to check whether the tag exists, and if
# so, whether the tree hash matches the tree hash of the package entry
# in the manifest.
src_to_uuid = get_src_to_uuid(doc)
for remote_repo in doc.user.remotes
if !(remote_repo.remote isa Remotes.GitHub)
continue
end
if !(remote_repo.root in keys(src_to_uuid))
continue
end
uuid = src_to_uuid[remote_repo.root]
repo_info = uuid_to_repo(doc, uuid)
if repo_info === nothing
continue
end
if remote_repo.remote != repo_info[1] || remote_repo.commit != repo_info[2]
continue
end
# Looks like it's been guessed -- let's check if it matches the
# tree hash from the package entry
uuid_to_version_info = get_uuid_to_version_info(doc)
tree_hash = uuid_to_version_info[uuid][2]
remote = remote_repo.remote
repo = remote.user * "/" * remote.repo
tag_guess = remote_repo.commit
tag_ref = tag(repo, tag_guess)
if tag_ref === nothing
@docerror(doc, :linkcheck_remotes, "linkcheck (remote) '$(repo)' error while getting tag '$(tag_guess)'. $(GITHUB_ERROR_ADVICE)")
return
end
if tag_ref["object"]["type"] != "commit"
@docerror(doc, :linkcheck_remotes, "linkcheck (remote) '$(repo)' tag '$(tag_guess)' does not point to a commit. $(GITHUB_ERROR_ADVICE)")
return
end
commit_sha = tag_ref["object"]["sha"]
git_commit = gitcommit(repo, commit_sha)
actual_tree_hash = git_commit["tree"]["sha"]
if string(tree_hash) != actual_tree_hash
@docerror(
doc,
:linkcheck_remotes,
"linkcheck (remote) '$(repo)' tag '$(tag_guess)' points to tree hash $(actual_tree_hash), but package entry has $(tree_hash). $(GITHUB_ERROR_ADVICE)"
)
end
end
end