#!/usr/bin/env python import glob import os.path import re import sys 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') if __name__ == "__main__": print(120 * "-") check_structure() check_examples() check_links() print(120 * "-") if warnings > 0: sys.exit(1)