blob: b7f42a1662aaf858f61b67b82a960ad6d4038c7e [file] [edit]
# -*- coding: utf-8 -*-
"""Check for absolute links to documents in the current Sphinx project."""
from __future__ import annotations
import io
import posixpath
import sys
import tempfile
from pathlib import Path
from typing import Collection, Dict, Iterator, Sequence
from urllib.parse import unquote, urlsplit
from llvm_sphinx.help import venv_help
try:
from docutils import nodes
from markdown_it import MarkdownIt
from sphinx.application import Sphinx
from sphinx.util import logging
except ImportError as err:
print(venv_help(err), file=sys.stderr)
raise
__version__ = "1.0"
logger = logging.getLogger("llvm_sphinx.ext.absolute_links")
def setup(app: Sphinx) -> Dict[str, object]:
app.add_config_value("llvm_sphinx_doc_url_prefixes", (), "env", [list, tuple])
app.connect("source-read", check_markdown_doc_links)
app.connect("doctree-read", check_absolute_doc_links)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}
def _markdown_links(source: str) -> Iterator[tuple[str, int | None]]:
"""Yield link destinations and approximate source lines from Markdown."""
for token in MarkdownIt("commonmark").parse(source):
if not token.children:
continue
line = token.map[0] + 1 if token.map else None
for child in token.children:
if child.type != "link_open":
continue
destination = child.attrGet("href")
if destination:
yield destination, line
def _docname_from_relative_html_link(
uri: str, from_docname: str, found_docs: Collection[str]
) -> str | None:
"""Map a relative generated-HTML link to a Sphinx document name."""
parsed = urlsplit(uri)
if parsed.scheme or parsed.netloc:
return None
path = unquote(parsed.path)
if not path.endswith(".html"):
return None
target = path[: -len(".html")]
if target.startswith("/"):
docname = target.lstrip("/")
else:
docname = posixpath.normpath(
posixpath.join(posixpath.dirname(from_docname), target)
)
return docname if docname in found_docs else None
def check_markdown_doc_links(app: Sphinx, docname: str, source: list[str]) -> None:
"""Diagnose nonportable Markdown links to Sphinx documents."""
if not app.config.llvm_sphinx_doc_url_prefixes:
return
if Path(app.env.doc2path(docname)).suffix != ".md":
return
for uri, line in _markdown_links(source[0]):
location = (docname, line)
if uri.startswith("project:"):
destination = uri[len("project:") :]
parsed = urlsplit(destination)
source_suffix = Path(unquote(parsed.path)).suffix
if parsed.fragment and source_suffix != ".md":
advice = "use a Sphinx 'ref' role for an explicit label"
else:
advice = f"use the relative source path {destination!r} instead"
logger.warning(
"Markdown link uses the nonportable 'project:' scheme; %s: %s",
advice,
uri,
location=location,
type="llvm_sphinx",
subtype="nonportable-doc-link",
)
continue
target_docname = _docname_from_relative_html_link(
uri, docname, app.env.found_docs
)
if target_docname is None:
continue
source_suffix = Path(app.env.doc2path(target_docname)).suffix
parsed = urlsplit(uri)
if parsed.fragment and source_suffix != ".md":
logger.warning(
"Markdown link points to generated HTML for document %r; "
"use a Sphinx 'ref' role for the section instead: %s",
target_docname,
uri,
location=location,
type="llvm_sphinx",
subtype="nonportable-doc-link",
)
continue
suggested = unquote(parsed.path)[: -len(".html")] + source_suffix
if parsed.fragment:
suggested += "#" + unquote(parsed.fragment)
logger.warning(
"Markdown link points to generated HTML for document %r; "
"use the relative source path %r instead: %s",
target_docname,
suggested,
uri,
location=location,
type="llvm_sphinx",
subtype="nonportable-doc-link",
)
def _url_prefix_parts(prefix: str) -> tuple[str, str] | None:
try:
parsed = urlsplit(prefix)
port = parsed.port
except ValueError:
return None
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
if port not in (None, 80, 443):
return None
path = parsed.path
if not path.endswith("/"):
path += "/"
return parsed.hostname.lower(), path
def _docname_from_url(
uri: str, prefixes: Sequence[str], found_docs: Collection[str]
) -> str | None:
try:
parsed = urlsplit(uri)
port = parsed.port
except ValueError:
return None
if (
parsed.scheme not in ("http", "https")
or not parsed.hostname
or port not in (None, 80, 443)
):
return None
hostname = parsed.hostname.lower()
path = unquote(parsed.path)
for prefix in prefixes:
prefix_parts = _url_prefix_parts(prefix)
if prefix_parts is None:
continue
prefix_hostname, prefix_path = prefix_parts
if hostname != prefix_hostname or not path.startswith(prefix_path):
continue
relative_path = path[len(prefix_path) :]
if not relative_path:
docname = "index"
elif relative_path.endswith(".html"):
docname = relative_path[: -len(".html")]
elif relative_path.endswith("/"):
docname = relative_path + "index"
else:
continue
if docname in found_docs:
return docname
return None
def check_absolute_doc_links(app: Sphinx, doctree: nodes.document) -> None:
"""Diagnose absolute URLs that name documents in this Sphinx project."""
prefixes = app.config.llvm_sphinx_doc_url_prefixes
if not prefixes:
return
for node in doctree.findall(nodes.reference):
uri = node.get("refuri")
if not uri:
continue
docname = _docname_from_url(uri, prefixes, app.env.found_docs)
if docname is None:
continue
logger.warning(
"absolute URL points to document %r in this Sphinx project; "
"use a relative source link or an internal 'ref' role instead: %s",
docname,
uri,
location=node,
type="llvm_sphinx",
subtype="absolute-doc-link",
)
# -----------------------------------------------------------------------------
# Test code only below:
# -----------------------------------------------------------------------------
def _build_test_docs() -> str:
"""Build the reST and Markdown test inputs and return Sphinx warnings."""
srcdir = Path(__file__).resolve().parent / "absolute_links_test"
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
warnings = io.StringIO()
app = Sphinx(
srcdir=srcdir,
confdir=srcdir,
outdir=tmp_path / "out",
doctreedir=tmp_path / "doctrees",
buildername="html",
freshenv=True,
warningiserror=False,
status=None,
warning=warnings,
)
app.build()
return warnings.getvalue()
def run_tests() -> None:
warnings = _build_test_docs()
expected_urls = (
"https://example.test/docs/target.html#target-section",
"https://example.test/docs/",
"http://www.example.test/docs/target.html?view=1#target-section",
)
for url in expected_urls:
if url not in warnings:
raise AssertionError(f"expected a warning for {url}")
if warnings.count("absolute URL points to document") != len(expected_urls):
raise AssertionError(f"unexpected Sphinx warnings:\n{warnings}")
expected_nonportable_links = (
"project:target.md",
"project:target.md#target-section",
"target.html",
"target.html#target-section",
"project:target.md#target-document",
"target.html#target-document",
"project:rest.rst#rest-section",
"rest.html#rest-section",
"project:rest.rst",
)
for link in expected_nonportable_links:
if link not in warnings:
raise AssertionError(f"expected a warning for {link}")
nonportable_count = warnings.count(
"Markdown link uses the nonportable 'project:' scheme"
) + warnings.count("Markdown link points to generated HTML")
if nonportable_count != len(expected_nonportable_links):
raise AssertionError(f"unexpected Sphinx warnings:\n{warnings}")
if warnings.count("use a Sphinx 'ref' role for") != 2:
raise AssertionError(f"unexpected Sphinx warnings:\n{warnings}")