#!/usr/bin/env python import glob import os.path import re import sys import urllib.parse import yaml warnings = 0 def report(rule, location, description) -> None: global warnings warnings += 1 print(f'{warnings:3}. {location}: {description} [{rule}]') def check_structure() -> None: expected_sections = [ "Template parameters", "Specializations", "Iterator invalidation", "Requirements", "Member types", "Member functions", "Member variables", "Static functions", "Non-member functions", "Literals", "Helper classes", "Parameters", "Return value", "Exception safety", "Exceptions", "Complexity", "Possible implementation", "Default definition", "Notes", "Examples", "See also", "Version history", ] required_sections = [ "Examples", "Version history", ] files = sorted(glob.glob("api/**/*.md", recursive=True)) for file in files: with open(file) as file_content: section_idx = -1 # the index of the current h2 section existing_sections = [] # the list of h2 sections in the file in_initial_code_example = False # whether we are inside the first code example block previous_line = None # the previous read line h1sections = 0 # the number of h1 sections in the file last_overload = 0 # the last seen overload number in the code example documented_overloads = {} # the overloads that have been documented in the current block current_section = None # the name of the current section for lineno, original_line in enumerate(file_content.readlines()): line = original_line.strip() if line.startswith("# "): h1sections += 1 # there should only be one top-level title if h1sections > 1: report("structure/unexpected_section", f"{file}:{lineno+1}", f'unexpected top-level title "{line}"') h1sections = 1 # Overview pages should have a better title if line == "# Overview": report("style/title", f"{file}:{lineno+1}", 'overview pages should have a better title than "Overview"') # lines longer than 160 characters are bad (unless they are tables) if len(line) > 160 and "|" not in line: report("whitespace/line_length", f"{file}:{lineno+1} ({current_section})", f"line is too long ({len(line)} vs. 160 chars)") # sections in `` comments are treated as present if line.startswith("") existing_sections.append(current_section) # check if sections are correct if line.startswith("## "): # before starting a new section, check if the previous one documented all overloads if current_section in documented_overloads and last_overload != 0: if len(documented_overloads[current_section]) > 0 and len(documented_overloads[current_section]) != last_overload: expected = list(range(1, last_overload+1)) undocumented = [x for x in expected if x not in documented_overloads[current_section]] unexpected = [x for x in documented_overloads[current_section] if x not in expected] if len(undocumented): report("style/numbering", f"{file}:{lineno} ({current_section})", f'undocumented overloads: {", ".join([f"({x})" for x in undocumented])}') if len(unexpected): report("style/numbering", f"{file}:{lineno} ({current_section})", f'unexpected overloads: {", ".join([f"({x})" for x in unexpected])}') current_section = line.strip("## ") existing_sections.append(current_section) if current_section in expected_sections: idx = expected_sections.index(current_section) if idx <= section_idx: report("structure/section_order", f"{file}:{lineno+1}", f'section "{current_section}" is in an unexpected order (should be before "{expected_sections[section_idx]}")') section_idx = idx elif "index.md" not in file: # index.md files may have a different structure report("structure/unknown_section", f"{file}:{lineno+1}", f'section "{current_section}" is not part of the expected sections') # collect the numbered items of the current section to later check if they match the number of overloads if last_overload != 0 and not in_initial_code_example: if len(original_line) and original_line[0].isdigit(): number = int(re.findall(r"^(\d+).", original_line)[0]) if current_section not in documented_overloads: documented_overloads[current_section] = [] documented_overloads[current_section].append(number) # code example if line == "```cpp" and section_idx == -1: in_initial_code_example = True if in_initial_code_example and line.startswith("//") and line not in ["// since C++20", "// until C++20"]: # check numbering of overloads if any(map(str.isdigit, line)): number = int(re.findall(r"\d+", line)[0]) if number != last_overload + 1: report("style/numbering", f"{file}:{lineno+1}", f"expected number ({number}) to be ({last_overload +1 })") last_overload = number if any(map(str.isdigit, line)) and "(" not in line: report("style/numbering", f"{file}:{lineno+1}", f"number should be in parentheses: {line}") if line == "```" and in_initial_code_example: in_initial_code_example = False # consecutive blank lines are bad if line == "" and previous_line == "": report("whitespace/blank_lines", f"{file}:{lineno}-{lineno+1} ({current_section})", "consecutive blank lines") # check that non-example admonitions have titles untitled_admonition = re.match(r"^(\?\?\?|!!!) ([^ ]+)$", line) if untitled_admonition and untitled_admonition.group(2) != "example": report("style/admonition_title", f"{file}:{lineno} ({current_section})", f'"{untitled_admonition.group(2)}" admonitions should have a title') previous_line = line if "index.md" not in file: # index.md files may have a different structure for required_section in required_sections: if required_section not in existing_sections: report("structure/missing_section", f"{file}:{lineno+1}", f'required section "{required_section}" was not found') def check_examples() -> None: example_files = sorted(glob.glob("examples/*.cpp")) markdown_files = sorted(glob.glob("**/*.md", recursive=True)) # check if every example file is used in at least one markdown file for example_file in example_files: example_file = os.path.join("examples", os.path.basename(example_file)) found = False for markdown_file in markdown_files: content = " ".join(open(markdown_file).readlines()) if example_file in content: found = True break if not found: report("examples/missing", f"{example_file}", "example file is not used in any documentation file") def check_links() -> None: """Check that every entry in the navigation (nav in mkdocs.yml) links to at most one file. If a file is linked more than once, then the first entry is repeated. See https://github.com/nlohmann/json/issues/4564 for the issue in this project and https://github.com/mkdocs/mkdocs/issues/3428 for the root cause. The issue can be fixed by merging the keys, so - 'NLOHMANN_JSON_VERSION_MAJOR': api/macros/nlohmann_json_version_major.md - 'NLOHMANN_JSON_VERSION_MINOR': api/macros/nlohmann_json_version_major.md would be replaced with - 'NLOHMANN_JSON_VERSION_MAJOR, NLOHMANN_JSON_VERSION_MINOR': api/macros/nlohmann_json_version_major.md """ file_with_path = {} def collect_links(node, path="") -> None: if isinstance(node, list): for x in node: collect_links(x, path) elif isinstance(node, dict): for p, x in node.items(): collect_links(x, path + "/" + p) else: if node not in file_with_path: file_with_path[node] = [] file_with_path[node].append(path) with open("../mkdocs.yml") as mkdocs_file: # see https://github.com/yaml/pyyaml/issues/86#issuecomment-1042485535 yaml.add_multi_constructor("tag:yaml.org,2002:python/name", lambda loader, suffix, node: None, Loader=yaml.SafeLoader) yaml.add_multi_constructor("!ENV", lambda loader, suffix, node: None, Loader=yaml.SafeLoader) y = yaml.safe_load(mkdocs_file) collect_links(y["nav"]) for duplicate_file in [x for x in file_with_path if len(file_with_path[x]) > 1]: file_list = [f'"{x}"' for x in file_with_path[duplicate_file]] file_list_str = ", ".join(file_list) report("nav/duplicate_files", "mkdocs.yml", f'file "{duplicate_file}" is linked with multiple keys in "nav": {file_list_str}; only one is rendered properly, see #4564') FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})") INLINE_CODE_RE = re.compile(r"(`+).+?\1") def markdown_lines(file): """Yield (lineno, line) for all lines outside fenced code blocks.""" fence = None with open(file, encoding="utf-8") as content: for lineno, line in enumerate(content, 1): line = line.rstrip("\n") match = FENCE_RE.match(line) if fence is None: if match: fence = match.group(1) else: yield lineno, line elif match and line.strip() == match.group(1) and match.group(1)[0] == fence[0] \ and len(match.group(1)) >= len(fence): fence = None def check_example_titles() -> None: """On API pages with more than one example, every example needs a title of the form "Example: ...".""" example_re = re.compile(r'^\s*(?:\?\?\?\+?|!!!) example(?: "(.*)")?\s*$') for file in sorted(glob.glob("api/**/*.md", recursive=True)): examples = [(lineno, m.group(1)) for lineno, line in markdown_lines(file) if (m := example_re.match(line))] if len(examples) < 2: continue for lineno, title in examples: if title is None or not title.startswith("Example: "): report("style/example_title", f"{file}:{lineno}", f'pages with several examples need titles like "Example: ..." (found: {title!r})') def check_heading_levels() -> None: """Headings start at level 1 and never skip a level.""" heading_re = re.compile(r"^(#{1,6})\s|^]") for file in sorted(glob.glob("**/*.md", recursive=True)): previous = 0 for lineno, line in markdown_lines(file): match = heading_re.match(line) if not match: continue level = len(match.group(1)) if match.group(1) else int(match.group(2)) if previous == 0 and level != 1: report("structure/heading_level", f"{file}:{lineno}", f"first heading should have level 1, not {level}") elif level > previous + 1 and previous != 0: report("structure/heading_level", f"{file}:{lineno}", f"heading level jumps from {previous} to {level}") previous = level def check_image_alt_text() -> None: """Images need an alternative text.""" empty_alt_re = re.compile(r"!\[\s*\][(\[]") img_re = re.compile(r"]*>", re.IGNORECASE) alt_re = re.compile(r'\balt\s*=\s*"[^"]*\S[^"]*"', re.IGNORECASE) for file in sorted(glob.glob("**/*.md", recursive=True)): for lineno, line in markdown_lines(file): line = INLINE_CODE_RE.sub("", line) if empty_alt_re.search(line) or any(not alt_re.search(tag) for tag in img_re.findall(line)): report("style/image_alt_text", f"{file}:{lineno}", "image without alternative text") def check_header_links() -> None: """Links to the documentation in the library's headers point to existing pages.""" url_re = re.compile(r"https://json\.nlohmann\.me/([^\s#)>\"']*)") for header in sorted(glob.glob("../../../include/nlohmann/**/*.hpp", recursive=True)): with open(header, encoding="utf-8") as content: for lineno, line in enumerate(content, 1): for match in url_re.finditer(line): path = urllib.parse.unquote(match.group(1)).strip("/") if path and not (os.path.isfile(f"{path}.md") or os.path.isfile(f"{path}/index.md")): report("links/header_link", f"{os.path.relpath(header, '../../..')}:{lineno}", f'link to "{match.group(0)}" does not point to a documentation page') def check_docset() -> None: """Every API page and every macro has an entry in the docset index; no entry points to a missing page.""" entry_re = re.compile(r"VALUES \('((?:[^']|'')*)', '(\w+)', '([^']*)'\);") names_by_path = {} with open("../../docset/docSet.sql", encoding="utf-8") as sql: for name, _, path in entry_re.findall(sql.read()): names_by_path.setdefault(path, set()).add(name.replace("''", "'")) def to_path(page): if os.path.basename(page) == "index.md": return page[:-len("index.md")] + "index.html" return page[:-len(".md")] + "/index.html" pages = sorted(glob.glob("**/*.md", recursive=True)) for path in sorted(set(names_by_path) - {to_path(p) for p in pages}): report("docset/stale_entry", "../../docset/docSet.sql", f'entry "{path}" has no documentation page') for page in (p for p in pages if p.startswith("api/")): names = names_by_path.get(to_path(page)) if not names: report("docset/missing_entry", page, "page has no entry in docs/docset/docSet.sql") elif page.startswith("api/macros/") and os.path.basename(page) != "index.md": with open(page, encoding="utf-8") as content: text = content.read() match = re.search(r"^# (.+)$", text, re.MULTILINE) or re.search(r"

(.*?)

", text, re.DOTALL) title = re.sub(r"<[^>]+>|\s+", " ", match.group(1)) for macro in filter(None, (x.strip() for x in re.split(r"[,/]", title))): if macro not in names: report("docset/missing_macro", page, f'macro "{macro}" has no entry in docs/docset/docSet.sql') if __name__ == "__main__": print(120 * "-") check_structure() check_examples() check_links() check_example_titles() check_heading_levels() check_image_alt_text() check_header_links() check_docset() print(120 * "-") if warnings > 0: sys.exit(1)