diff options
| author | Yuriy Andamasov <yuriy@vyos.io> | 2026-05-10 22:26:55 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-10 22:26:55 +0300 |
| commit | e6e95c01f4ffe3ae5284a36e460eaeccc42b3486 (patch) | |
| tree | 7abb2fff20a463be0a8eb4207a66324785eee699 /scripts | |
| parent | 266acf2829fdcf809fe851203d8e34167075dd3f (diff) | |
| parent | 4ef7ffeb2b337b14699e7547b43ba2fe59a00422 (diff) | |
| download | vyos-documentation-e6e95c01f4ffe3ae5284a36e460eaeccc42b3486.tar.gz vyos-documentation-e6e95c01f4ffe3ae5284a36e460eaeccc42b3486.zip | |
Merge pull request #1945 from vyos/yuriy/doc-linter-myst
ci: extend doc-linter to MyST Markdown
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/doc-linter.py | 184 |
1 files changed, 124 insertions, 60 deletions
diff --git a/scripts/doc-linter.py b/scripts/doc-linter.py index 3dc7c2fc..5ccff488 100644 --- a/scripts/doc-linter.py +++ b/scripts/doc-linter.py @@ -27,6 +27,44 @@ MAC = r'([0-9A-F]{2}[:-]){5}([0-9A-F]{2})' NUMBER = r"([\s']\d+[\s'])" +SUPPORTED_EXTS = ('.md', '.rst', '.txt') + +# MyST / Markdown fenced code block: leading whitespace + 3+ backticks or 3+ colons. +# Same character and length-or-greater closes. +MD_FENCE_RE = re.compile(r'^(\s*)(`{3,}|:{3,})(.*)$') + +SUPPRESSION_MARKER_RE = { + 'rst': { + 'stop': re.compile(r'^\s*\.\.\s+stop_vyoslinter\s*$'), + 'start': re.compile(r'^\s*\.\.\s+start_vyoslinter\s*$'), + }, + 'md': { + 'stop': re.compile(r'^\s*%\s+stop_vyoslinter\s*$'), + 'start': re.compile(r'^\s*%\s+start_vyoslinter\s*$'), + }, +} + + +def is_suppression_marker(line, kind, in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext): + """Detect valid stop/start markers in the parser context where they apply. + + `% ...` is only valid in MyST Markdown (`.md`) at top level (outside any fence). + `.. ...` is valid in `.rst`/`.txt` at top level (outside RST code-block) and + in `.md` only when inside an `{eval-rst}` fence. + """ + if SUPPRESSION_MARKER_RE['md'][kind].match(line): + return file_ext == '.md' and not in_md_fence + if not SUPPRESSION_MARKER_RE['rst'][kind].match(line): + return False + if in_rst_codeblock: + return False + if file_ext in ('.rst', '.txt'): + return True + if in_md_fence: + return md_fence_is_eval_rst + return False + def lint_mac(cnt, line): mac = re.search(MAC, line, re.I) @@ -79,65 +117,91 @@ def lint_linelen(cnt, line): def handle_file_action(filepath): errors = [] - try: - with open(filepath) as fp: - line = fp.readline() - cnt = 1 - test_line_lenght = True - start_vyoslinter = True - indentation = 0 - while line: - # search for ignore linter comments in lines - if ".. stop_vyoslinter" in line: - start_vyoslinter = False - if ".. start_vyoslinter" in line: - start_vyoslinter = True - if start_vyoslinter: - # ignore every '.. code-block::' for line lenght - # rst code-block have its own style in html the format in rst - # and the build page must be the same - if test_line_lenght is False: - if len(line) > indentation: - #print(f"'{line}'") - #print(indentation) - if line[indentation].isspace() is False: - test_line_lenght = True - - if ".. code-block::" in line: - test_line_lenght = False - indentation = 0 - for i in line: - if i.isspace(): - indentation = indentation + 1 - else: - break - - err_mac = lint_mac(cnt, line.strip()) - # disable mac detection for the moment, too many false positives - err_mac = None - err_ip4 = lint_ipv4(cnt, line.strip()) - err_ip6 = lint_ipv6(cnt, line.strip()) - if test_line_lenght: - err_len = lint_linelen(cnt, line) + file_ext = os.path.splitext(filepath)[1].lower() + # Stack of open MD/MyST fences: (char, min_len). Supports nesting like + # `:::{note}` containing `::::{code-block}`, where the inner opener does + # not close the outer note (CommonMark closing rule: matching closer must + # have no info string after the fence chars). + md_fence_stack = [] + md_fence_is_eval_rst = False + in_rst_codeblock = False + rst_codeblock_indent = 0 + start_vyoslinter = True + cnt = 0 + + with open(filepath) as fp: + for cnt, line in enumerate(fp, start=1): + # MD/MyST fenced code block tracking (``` or :::). + fence_match = MD_FENCE_RE.match(line) + if fence_match: + fence = fence_match.group(2) + fence_char = fence[0] + fence_len = len(fence) + info = fence_match.group(3).strip() + # A closer matches the top of the stack (same char, len >= + # opener) and has no info string. Anything else opens a new + # (possibly nested) fence. + is_closer = ( + md_fence_stack + and not info + and fence_char == md_fence_stack[-1][0] + and fence_len >= md_fence_stack[-1][1] + ) + if is_closer: + md_fence_stack.pop() + if not md_fence_stack: + md_fence_is_eval_rst = False + else: + if not md_fence_stack: + md_fence_is_eval_rst = info.startswith('{eval-rst}') + md_fence_stack.append((fence_char, fence_len)) + + in_md_fence = bool(md_fence_stack) + + # RST `.. code-block::` tracking (existing semantics for .rst/.txt). + # Each `.. code-block::` directive resets the tracked indent so a + # later dedent past that column exits the block — even when the + # directive itself appears inside an already-open outer block. + if in_rst_codeblock: + if len(line) > rst_codeblock_indent and not line[rst_codeblock_indent].isspace(): + in_rst_codeblock = False + if ".. code-block::" in line: + in_rst_codeblock = True + rst_codeblock_indent = 0 + for ch in line: + if ch.isspace(): + rst_codeblock_indent += 1 else: - err_len = None - if err_mac: - errors.append(err_mac) - if err_ip4: - errors.append(err_ip4) - if err_ip6: - errors.append(err_ip6) - if err_len: - errors.append(err_len) - - line = fp.readline() - cnt += 1 - - # ensure linter was not stop on top and forgot to tun on again - if start_vyoslinter == False: - errors.append((f"Don't forgett to turn linter back on", cnt, 'error')) - finally: - fp.close() + break + + if is_suppression_marker( + line, 'stop', in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext, + ): + start_vyoslinter = False + if is_suppression_marker( + line, 'start', in_md_fence, in_rst_codeblock, + md_fence_is_eval_rst, file_ext, + ): + start_vyoslinter = True + + if not start_vyoslinter: + continue + + test_line_length = not (in_md_fence or in_rst_codeblock) + + err_mac = lint_mac(cnt, line.strip()) + # disable mac detection for the moment, too many false positives + err_mac = None + err_ip4 = lint_ipv4(cnt, line.strip()) + err_ip6 = lint_ipv6(cnt, line.strip()) + err_len = lint_linelen(cnt, line) if test_line_length else None + for e in (err_mac, err_ip4, err_ip6, err_len): + if e: + errors.append(e) + + if not start_vyoslinter: + errors.append(("Don't forget to turn linter back on", cnt, 'error')) if len(errors) > 0: ''' @@ -156,14 +220,14 @@ def main(): try: files = ast.literal_eval(sys.argv[1]) for file in files: - if file[-4:] in [".rst", ".txt"] and "_build" not in file: + if file.endswith(SUPPORTED_EXTS) and "_build" not in file: if handle_file_action(file) is False: bool_error = False except Exception as e: for root, dirs, files in os.walk("docs"): path = root.split(os.sep) for file in files: - if file[-4:] in [".rst", ".txt"] and "_build" not in path: + if file.endswith(SUPPORTED_EXTS) and "_build" not in path: fpath = '/'.join(path) filepath = f"{fpath}/{file}" if handle_file_action(filepath) is False: |
