diff --git a/CLAUDE.md b/CLAUDE.md index dde9e53..998ae06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Python >=3.10, Markdown >=3.2. Markdown 3.0/3.1 can't import on modern Python (they need `pkg_resources`). - master is protected by a ruleset: changes go through PRs, and the `ci-ok` check (aggregates lint + the test matrix) must pass. - The whole extension is a single `Preprocessor` in `md_condition/preprocessor.py`, registered at priority 50. `makeExtension` in `__init__.py` is what lets `md_condition` work as a string name in MkDocs `markdown_extensions`. -- Known behaviors to keep in mind (don't change them silently, since they're user-visible): - - The symbol is interpolated into a regex unescaped and matched as a substring (`.*DEBUG.*`), so `DEBUG` also matches `#if DEBUG_X`. - - Nested `#if` blocks are not supported. `validate` only checks that the counts of `#if` and `#endif` are equal. - - Unbalanced blocks raise `AssertionError` from an `assert` statement (stripped under `python -O`). The tests rely on `AssertionError`. +- Syntax rules are documented in README "Rules" and are user-visible; keep README, tests, and code in sync when changing them. +- `ConditionSyntaxError` subclasses both `ValueError` and `AssertionError`; the latter keeps callers from <= 0.1.x (which used `assert`) working. Don't drop it without a major version bump. ## Release diff --git a/README.md b/README.md index 7bd3f5d..669548e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ not RELEASE
not RELEASE
``` +### Rules + +- `` is true when the configured `symbol` is exactly one of the listed names + (`DEBUG` does not match `DEBUG_X`). Names are separated by spaces and/or `|`, so + `#if A | B` and `#if A|B` mean the same. With no `symbol` configured, every `#if` is false. +- `#else` is optional; nested `#if` blocks are not supported. +- Malformed blocks (unclosed `#if`, stray `#else` / `#endif`, duplicate `#else`, nesting) raise + `md_condition.ConditionSyntaxError` with the offending line number. + ## MkDocs Integration In your mkdocs.yml add this to markdown_extensions. diff --git a/md_condition/__init__.py b/md_condition/__init__.py index 63b05e5..03ece4b 100644 --- a/md_condition/__init__.py +++ b/md_condition/__init__.py @@ -1,6 +1,7 @@ from md_condition.extension import ConditionExtension +from md_condition.preprocessor import ConditionSyntaxError -__all__ = ["ConditionExtension", "makeExtension"] +__all__ = ["ConditionExtension", "ConditionSyntaxError", "makeExtension"] def makeExtension(**kwargs) -> ConditionExtension: diff --git a/md_condition/preprocessor.py b/md_condition/preprocessor.py index e45bd4d..b6ec8cb 100644 --- a/md_condition/preprocessor.py +++ b/md_condition/preprocessor.py @@ -10,53 +10,62 @@ from md_condition.extension import ConditionExtension +class ConditionSyntaxError(ValueError, AssertionError): + """Malformed ``#if`` / ``#else`` / ``#endif`` blocks. + + Also an ``AssertionError`` for compatibility with <= 0.1.x, which used ``assert``. + """ + + class ConditionPreprocessor(Preprocessor): - RE_START = re.compile(r"") + RE_START = re.compile(r"") RE_END = re.compile(r"") RE_ELSE = re.compile(r"") + RE_NAME = re.compile(r"[^\s|]+") # names are separated by whitespace and/or `|` def __init__(self, md: Markdown, extension: ConditionExtension) -> None: super().__init__(md) - symbol = extension.getConfig("symbol") - self.re_symbol_start = re.compile(r"") - - def validate(self, lines: list[str]) -> None: - target_lines_start = [] - target_lines_end = [] - for index, line in enumerate(lines): - if self.RE_START.match(line): - target_lines_start.append(f"{index}. {line}") - if self.RE_END.match(line): - target_lines_end.append(f"{index}. {line}") - - assert len(target_lines_start) == len(target_lines_end), ( - f"Unbalanced if / endif blocks\n{target_lines_start}\n{target_lines_end}" - ) + self.symbol = extension.getConfig("symbol") def run(self, lines: list[str]) -> list[str]: - self.validate(lines) new_lines = [] - matching = False - symbol_matching = False - for line in lines: - start_head = False - if not matching: - if self.RE_START.match(line): - start_head = True - matching = True - symbol_match_start = self.re_symbol_start.match(line) - if symbol_match_start: - symbol_matching = True - else: - if self.RE_ELSE.match(line): - symbol_matching = not symbol_matching - continue - if self.RE_END.match(line): - matching = False - symbol_matching = False - continue - if not matching or symbol_matching and not start_head: + block_start = None # 1-based line of the open #if; None outside a block + seen_else = False + keep = True + for number, line in enumerate(lines, start=1): + start = self.RE_START.match(line) + if start: + if block_start is not None: + raise ConditionSyntaxError( + f"line {number}: nested #if is not supported " + f"(block opened at line {block_start})" + ) + block_start = number + seen_else = False + # `#if A B` / `#if A | B` is true when the symbol is one of the listed names + keep = self.symbol in self.RE_NAME.findall(start.group(1)) + continue + if self.RE_ELSE.match(line): + if block_start is None: + raise ConditionSyntaxError(f"line {number}: #else without #if") + if seen_else: + raise ConditionSyntaxError( + f"line {number}: duplicate #else in block opened at line {block_start}" + ) + seen_else = True + keep = not keep + continue + if self.RE_END.match(line): + if block_start is None: + raise ConditionSyntaxError(f"line {number}: #endif without #if") + block_start = None + keep = True + continue + if keep: new_lines.append(line) + if block_start is not None: + raise ConditionSyntaxError(f"line {block_start}: #if is never closed with #endif") + return new_lines diff --git a/tests/test_condition.py b/tests/test_condition.py index e5878ae..d733796 100644 --- a/tests/test_condition.py +++ b/tests/test_condition.py @@ -3,7 +3,7 @@ import markdown import pytest -from md_condition import ConditionExtension +from md_condition import ConditionExtension, ConditionSyntaxError @pytest.fixture @@ -89,12 +89,37 @@ def test_condition(md: markdown.Markdown, source: str, expected: str) -> None: @pytest.mark.parametrize( - "source", + ("symbol", "condition", "kept"), + [ + pytest.param("DEBUG", "DEBUG_X", False, id="no_prefix_match"), + pytest.param("DEBUG", "MY_DEBUG", False, id="no_suffix_match"), + pytest.param("DEBUG_X", "DEBUG_X", True, id="exact"), + pytest.param("C++", "C++", True, id="regex_metachars"), + pytest.param("C++", "CXX", False, id="regex_metachars_no_match"), + pytest.param("", "DEBUG", False, id="empty_symbol_matches_nothing"), + pytest.param("", "A | B", False, id="empty_symbol_ignores_separators"), + pytest.param("", "| A |", False, id="empty_symbol_edge_separators"), + # Issue #7: `|`-separated names, with or without spaces + pytest.param("THAT", "THIS | THAT | OTHER", True, id="pipe_spaced"), + pytest.param("THAT", "THIS|THAT|OTHER", True, id="pipe_compact"), + pytest.param("THAT", "THIS|THATX|OTHER", False, id="pipe_whole_name"), + pytest.param("OTHER", "THIS THAT|OTHER", True, id="mixed_separators"), + ], +) +def test_symbol_is_matched_as_whole_name(symbol: str, condition: str, kept: bool) -> None: + md = markdown.Markdown(extensions=[ConditionExtension(symbol=symbol)]) + output = md.convert(f"\nyes\n\nno\n") + assert output == ("yes
" if kept else "no
") + + +@pytest.mark.parametrize( + ("source", "message"), [ pytest.param( """\ # 1""", + "line 1: #if is never closed", id="missing_endif", ), pytest.param( @@ -103,15 +128,47 @@ def test_condition(md: markdown.Markdown, source: str, expected: str) -> None: # 1 """, + "line 4: #endif without #if", id="extra_endif", ), + pytest.param( + """\ + # 1 + """, + "line 2: #else without #if", + id="else_without_if", + ), + pytest.param( + """\ + + + + """, + "line 3: duplicate #else", + id="duplicate_else", + ), + pytest.param( + """\ + + + + """, + "line 2: nested #if is not supported", + id="nested", + ), ], ) -def test_unbalanced(md: markdown.Markdown, source: str) -> None: - with pytest.raises(AssertionError, match="Unbalanced if / endif blocks"): +def test_malformed_blocks(md: markdown.Markdown, source: str, message: str) -> None: + with pytest.raises(ConditionSyntaxError, match=message): convert(md, source) +def test_error_is_still_an_assertion_error(md: markdown.Markdown) -> None: + # Backward compatibility with <= 0.1.x, which raised via `assert` + with pytest.raises(AssertionError): + convert(md, "") + + def test_make_extension_by_name() -> None: md = markdown.Markdown( extensions=["md_condition"],