| #!/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 `<!-- NOLINT -->` comments are treated as present | |
| nolint_match = re.match(r"<!--\s*NOLINT\s+(.*?)\s*-->", line) | |
| if nolint_match: | |
| current_section = nolint_match.group(1) | |
| 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[3:] | |
| 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+1} ({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|^<h([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"<img\b[^>]*>", 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') | |
| if __name__ == "__main__": | |
| print(120 * "-") | |
| check_structure() | |
| check_examples() | |
| check_links() | |
| check_example_titles() | |
| check_heading_levels() | |
| check_image_alt_text() | |
| check_header_links() | |
| print(120 * "-") | |
| if warnings > 0: | |
| sys.exit(1) |