blob: 908ae53b8423f1f74cca00fcf0f180f23efeb650 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import pathlib
import re
import sys
from typing import Final, NamedTuple
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import docs_post_process as post_process
class Link(NamedTuple):
source_file: str
line_number: int
text: str
target: str
anchor: str | None
class Heading(NamedTuple):
text: str
anchor: str
class RefLink(NamedTuple):
source_file: str
line_number: int
text: str
target: str
# TODO: Should think more about whether scripts should use the _ convention or not
# The rationale for using it is that then we can port to non-script code more easily
# But for scripts *per se*, it does not make sense
_LINK_PATTERN: Final = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_HEADING_PATTERN: Final = re.compile(r"^#+\s+(.+)$")
_REF_LINK_PATTERN: Final = re.compile(r"\[([^\]]+)\]\(/ref/([^)]+)\)")
def _extract_links(file_path: pathlib.Path) -> list[Link]:
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
links = []
for line_number, line in enumerate(lines, start=1):
for match in _LINK_PATTERN.finditer(line):
text = match.group(1)
target = match.group(2)
if target.startswith("/"):
continue
if target.startswith("http://") or target.startswith("https://") or target.startswith("mailto:"):
continue
anchor = None
if "#" in target:
target, anchor = target.split("#", 1)
links.append(Link(file_path.name, line_number, text, target, anchor))
return links
def _extract_headings(file_path: pathlib.Path) -> list[Heading]:
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
headings = []
for line in lines:
match = _HEADING_PATTERN.match(line)
if match:
text = match.group(1)
anchor = post_process.generate_heading_id(text)
headings.append(Heading(text, anchor))
return headings
def _extract_ref_links(file_path: pathlib.Path) -> list[RefLink]:
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
links = []
for line_number, line in enumerate(lines, start=1):
for match in _REF_LINK_PATTERN.finditer(line):
text = match.group(1)
target = match.group(2)
if ":" in target:
target = target.split(":", 1)[0]
links.append(RefLink(file_path.name, line_number, text, target))
return links
def _validate_links(docs_dir: pathlib.Path, all_links: list[Link]) -> list[str]:
errors = []
existing_files = {f.stem for f in docs_dir.glob("*.md")}
heading_cache: dict[str, set[str]] = {}
for link in all_links:
if link.target == ".":
target_file = "index"
elif link.target:
if link.target.endswith(".html"):
errors.append(
f"{link.source_file}:{link.line_number}: Link should not include '.html' extension: '{link.target}'"
)
target_file = link.target.removesuffix(".html")
else:
target_file = link.target
else:
target_file = link.source_file.replace(".md", "")
if target_file not in existing_files:
errors.append(
f"{link.source_file}:{link.line_number}: "
f"Link to non-existent file '{link.target}' "
f"(expected {target_file}.md)"
)
continue
if link.anchor:
if target_file not in heading_cache:
target_path = docs_dir / f"{target_file}.md"
headings = _extract_headings(target_path)
heading_cache[target_file] = {h.anchor for h in headings}
if link.anchor not in heading_cache[target_file]:
errors.append(
f"{link.source_file}:{link.line_number}: "
f"Link to non-existent anchor '#{link.anchor}' in '{target_file}'"
)
return errors
def _validate_ref_links(project_root: pathlib.Path, all_ref_links: list[RefLink]) -> list[str]:
errors = []
for link in all_ref_links:
file_path = project_root / link.target
if not file_path.exists():
errors.append(f"{link.source_file}:{link.line_number}: Ref link to non-existent file '/ref/{link.target}'")
return errors
def main() -> None:
docs_dir = pathlib.Path("atr/docs")
if not docs_dir.exists():
print(f"Error: {docs_dir} not found", file=sys.stderr)
sys.exit(1)
project_root = docs_dir.parent.parent
all_links = []
all_ref_links = []
for md_file in docs_dir.glob("*.md"):
links = _extract_links(md_file)
all_links.extend(links)
ref_links = _extract_ref_links(md_file)
all_ref_links.extend(ref_links)
errors = _validate_links(docs_dir, all_links)
errors.extend(_validate_ref_links(project_root, all_ref_links))
if errors:
print("Documentation link validation errors:\n", file=sys.stderr)
for error in errors:
print(error, file=sys.stderr)
suffix = "s" if (len(errors) > 1) else ""
print(f"\nFound {len(errors)} error{suffix}", file=sys.stderr)
sys.exit(1)
print(f"Validated {len(all_links)} links across {len(list(docs_dir.glob('*.md')))} files")
print(f"Validated {len(all_ref_links)} ref links")
print("All links are valid")
if __name__ == "__main__":
main()