diff --git a/doc_build/iso_clause_lint.py b/doc_build/iso_clause_lint.py index d728f79..f05731f 100644 --- a/doc_build/iso_clause_lint.py +++ b/doc_build/iso_clause_lint.py @@ -21,6 +21,7 @@ """ import json +import re import subprocess import sys from dataclasses import dataclass, field @@ -81,6 +82,50 @@ def format(self, *, context: int = DEFAULT_CONTEXT_LINES, display_path: Optional return '\n'.join(lines) +_HTML_COMMENT_RE = re.compile(r'^\s*\s*$', re.DOTALL) + + +def _is_html_comment_block(block: dict) -> bool: + """True if *block* is an HTML comment (possibly wrapped in a sourcepos Div).""" + candidates = [block] + if block.get("t") == "Div": + candidates = block.get("c", [None, []])[1] + return all( + b.get("t") == "RawBlock" + and len(b.get("c", [])) == 2 + and b["c"][0] == "html" + and _HTML_COMMENT_RE.match(b["c"][1]) + for b in candidates + ) + + +def _strip_html_comment_lines( + lines: List[Tuple[int, str]], +) -> List[Tuple[int, str]]: + """Remove lines that are (part of) an HTML comment. + + Handles single-line ```` and multi-line comments where + ```` are on separate lines. Lines that contain both + non-comment text and a comment tag are kept (conservative). + """ + result: List[Tuple[int, str]] = [] + in_comment = False + for lineno, text in lines: + stripped = text.strip() + if not in_comment: + if _HTML_COMMENT_RE.match(text): + continue + if stripped.startswith("" in stripped: + in_comment = False + continue + return result + + # --------------------------------------------------------------------------- # Core checker # --------------------------------------------------------------------------- @@ -145,11 +190,11 @@ def check_file(path: Path) -> List[Violation]: # The first line after the parent heading is raw_lines[cur_lineno] # (0-indexed), and the last line before the child heading is # raw_lines[lineno - 2] (0-indexed). - body_lines = [ + body_lines = _strip_html_comment_lines([ (i + 1, raw_lines[i]) for i in range(cur_lineno, lineno - 1) if i < len(raw_lines) and raw_lines[i].strip() - ] + ]) violations.append(Violation( file=path, heading_lineno=cur_lineno, @@ -170,8 +215,9 @@ def check_file(path: Path) -> List[Violation]: body_seen = False else: - # Any non-heading top-level block is body content. - if current_heading is not None: + # Any non-heading top-level block is body content, unless it + # is an HTML comment which is invisible in rendered output. + if current_heading is not None and not _is_html_comment_block(block): body_seen = True return violations diff --git a/tests/test_iso_clause_lint.py b/tests/test_iso_clause_lint.py new file mode 100644 index 0000000..b5048cc --- /dev/null +++ b/tests/test_iso_clause_lint.py @@ -0,0 +1,205 @@ +"""Tests for doc_build.iso_clause_lint — focused on HTML comment handling.""" + +import tempfile +import unittest +from pathlib import Path + +from doc_build.iso_clause_lint import ( + _is_html_comment_block, + _strip_html_comment_lines, + check_file, +) + + +class TestIsHtmlCommentBlock(unittest.TestCase): + """Unit tests for the _is_html_comment_block helper.""" + + def test_single_line_raw_block(self): + block = {"t": "RawBlock", "c": ["html", "\n"]} + self.assertTrue(_is_html_comment_block(block)) + + def test_multi_line_raw_block(self): + block = {"t": "RawBlock", "c": ["html", "\n"]} + self.assertTrue(_is_html_comment_block(block)) + + def test_sourcepos_div_wrapping_comment(self): + block = { + "t": "Div", + "c": [ + ["", [], [["wrapper", "1"], ["data-pos", "3:1-4:1"]]], + [{"t": "RawBlock", "c": ["html", "\n"]}], + ], + } + self.assertTrue(_is_html_comment_block(block)) + + def test_non_comment_raw_block(self): + block = {"t": "RawBlock", "c": ["html", "
not a comment
"]} + self.assertFalse(_is_html_comment_block(block)) + + def test_para_block(self): + block = {"t": "Para", "c": [{"t": "Str", "c": "text"}]} + self.assertFalse(_is_html_comment_block(block)) + + def test_div_wrapping_para(self): + block = { + "t": "Div", + "c": [ + ["", [], []], + [{"t": "Para", "c": [{"t": "Str", "c": "text"}]}], + ], + } + self.assertFalse(_is_html_comment_block(block)) + + def test_non_html_raw_block(self): + block = {"t": "RawBlock", "c": ["latex", "\\newpage"]} + self.assertFalse(_is_html_comment_block(block)) + + +class TestStripHtmlCommentLines(unittest.TestCase): + """Unit tests for _strip_html_comment_lines.""" + + def test_single_line_comment_removed(self): + lines = [(1, ""), (2, "real text")] + self.assertEqual(_strip_html_comment_lines(lines), [(2, "real text")]) + + def test_multi_line_comment_removed(self): + lines = [ + (1, ""), + (4, "real text"), + ] + self.assertEqual(_strip_html_comment_lines(lines), [(4, "real text")]) + + def test_long_single_line_iso_comment(self): + comment = ( + "" + ) + lines = [(1, comment), (2, "real text")] + self.assertEqual(_strip_html_comment_lines(lines), [(2, "real text")]) + + def test_no_comments(self): + lines = [(1, "first"), (2, "second")] + self.assertEqual(_strip_html_comment_lines(lines), lines) + + def test_empty_input(self): + self.assertEqual(_strip_html_comment_lines([]), []) + + def test_all_comments(self): + lines = [(1, ""), (2, "")] + self.assertEqual(_strip_html_comment_lines(lines), []) + + def test_multi_line_comment_between_text(self): + lines = [ + (1, "before"), + (2, ""), + (5, "after"), + ] + self.assertEqual( + _strip_html_comment_lines(lines), + [(1, "before"), (5, "after")], + ) + + def test_multiple_multi_line_comments(self): + lines = [ + (1, ""), + (4, "text"), + (5, ""), + ] + self.assertEqual(_strip_html_comment_lines(lines), [(4, "text")]) + + +def _write_md(content: str) -> Path: + """Write content to a temp .md file and return its path.""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".md", delete=False, encoding="utf-8" + ) + f.write(content) + f.close() + return Path(f.name) + + +class TestCheckFileHtmlComments(unittest.TestCase): + """Integration tests: check_file with HTML comments between headings.""" + + def test_single_line_comment_not_flagged(self): + path = _write_md( + "# Parent\n\n\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(violations, []) + + def test_multi_line_comment_not_flagged(self): + path = _write_md( + "# Parent\n\n\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(violations, []) + + def test_real_body_text_still_flagged(self): + path = _write_md( + "# Parent\n\nReal body text.\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0].heading_text, "Parent") + + def test_comment_plus_body_text_still_flagged(self): + path = _write_md( + "# Parent\n\n\n\nReal body text.\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(len(violations), 1) + + def test_single_line_comment_excluded_from_body_lines(self): + path = _write_md( + "# Parent\n\n\n\nReal body text.\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(len(violations), 1) + raw_texts = [line for _, line in violations[0].body_lines] + for text in raw_texts: + self.assertNotIn("\n\nReal body text.\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(len(violations), 1) + raw_texts = [line for _, line in violations[0].body_lines] + for text in raw_texts: + self.assertNotIn("", text) + self.assertNotIn("TODO", text) + + def test_only_comments_between_sibling_headings(self): + """Comments between same-level headings should not trigger either.""" + path = _write_md( + "# First\n\n## Sub\n\nText.\n\n\n\n# Second\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(violations, []) + + def test_no_headings(self): + path = _write_md("Just some text.\n\n\n") + violations = check_file(path) + self.assertEqual(violations, []) + + def test_multiple_comments_between_headings(self): + path = _write_md( + "# Parent\n\n\n\n\n\n## Child\n\nText.\n" + ) + violations = check_file(path) + self.assertEqual(violations, []) + + +if __name__ == "__main__": + unittest.main()