From 78cf80657d4e79e185cf2ee96f86523d375d1d05 Mon Sep 17 00:00:00 2001 From: cssho Date: Mon, 26 Oct 2026 21:22:51 +0900 Subject: [PATCH 1/3] Match symbols as whole names and validate block structure Behavior changes: - #if names are compared exactly instead of via an unescaped substring regex: DEBUG no longer matches DEBUG_X, symbols with regex metacharacters (e.g. C++) work, and an empty symbol matches nothing (previously it matched every #if) - Malformed blocks raise ConditionSyntaxError with the line number, also under python -O. Nesting, stray #else/#endif, and duplicate #else are now detected instead of silently mis-rendering. ConditionSyntaxError subclasses AssertionError for compatibility. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012pKTVb5TrGn27R29rHHNck --- CLAUDE.md | 6 +-- README.md | 8 ++++ md_condition/__init__.py | 3 +- md_condition/preprocessor.py | 82 ++++++++++++++++++++---------------- tests/test_condition.py | 58 +++++++++++++++++++++++-- 5 files changed, 111 insertions(+), 46 deletions(-) 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..54e2a62 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ not RELEASE

not RELEASE

``` +### Rules + +- `` is true when the configured `symbol` is exactly one of the listed names + (`DEBUG` does not match `DEBUG_X`). 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..5a96fb5 100644 --- a/md_condition/preprocessor.py +++ b/md_condition/preprocessor.py @@ -10,53 +10,61 @@ 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"") 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` is true when the configured symbol is one of the listed names + keep = self.symbol in start.group(1).split() + 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..6bca2f4 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,30 @@ 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"), + ], +) +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 +121,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"], From 644f1dc40d4b5a2f9a3a8ac5d9c76f5e48e7956b Mon Sep 17 00:00:00 2001 From: cssho Date: Mon, 26 Oct 2026 21:27:45 +0900 Subject: [PATCH 2/3] Accept | as a name separator in #if (closes #7) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012pKTVb5TrGn27R29rHHNck --- README.md | 3 ++- md_condition/preprocessor.py | 5 +++-- tests/test_condition.py | 6 ++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54e2a62..669548e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ not RELEASE ### Rules - `` is true when the configured `symbol` is exactly one of the listed names - (`DEBUG` does not match `DEBUG_X`). With no `symbol` configured, every `#if` is false. + (`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. diff --git a/md_condition/preprocessor.py b/md_condition/preprocessor.py index 5a96fb5..ef9df3f 100644 --- a/md_condition/preprocessor.py +++ b/md_condition/preprocessor.py @@ -21,6 +21,7 @@ class ConditionPreprocessor(Preprocessor): RE_START = re.compile(r"") RE_END = re.compile(r"") RE_ELSE = re.compile(r"") + RE_NAME_SEP = re.compile(r"[\s|]+") def __init__(self, md: Markdown, extension: ConditionExtension) -> None: super().__init__(md) @@ -42,8 +43,8 @@ def run(self, lines: list[str]) -> list[str]: ) block_start = number seen_else = False - # `#if A B` is true when the configured symbol is one of the listed names - keep = self.symbol in start.group(1).split() + # `#if A B` / `#if A | B` is true when the symbol is one of the listed names + keep = self.symbol in self.RE_NAME_SEP.split(start.group(1)) continue if self.RE_ELSE.match(line): if block_start is None: diff --git a/tests/test_condition.py b/tests/test_condition.py index 6bca2f4..68de308 100644 --- a/tests/test_condition.py +++ b/tests/test_condition.py @@ -97,6 +97,12 @@ def test_condition(md: markdown.Markdown, source: str, expected: str) -> None: 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"), + # 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: From 77140e52e5f34a12f2b418d20797cac5fc2db7b5 Mon Sep 17 00:00:00 2001 From: cssho Date: Mon, 26 Oct 2026 21:28:02 +0900 Subject: [PATCH 3/3] Ignore leading/trailing separators when parsing #if names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012pKTVb5TrGn27R29rHHNck --- md_condition/preprocessor.py | 4 ++-- tests/test_condition.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/md_condition/preprocessor.py b/md_condition/preprocessor.py index ef9df3f..b6ec8cb 100644 --- a/md_condition/preprocessor.py +++ b/md_condition/preprocessor.py @@ -21,7 +21,7 @@ class ConditionPreprocessor(Preprocessor): RE_START = re.compile(r"") RE_END = re.compile(r"") RE_ELSE = re.compile(r"") - RE_NAME_SEP = re.compile(r"[\s|]+") + RE_NAME = re.compile(r"[^\s|]+") # names are separated by whitespace and/or `|` def __init__(self, md: Markdown, extension: ConditionExtension) -> None: super().__init__(md) @@ -44,7 +44,7 @@ def run(self, lines: list[str]) -> list[str]: 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_SEP.split(start.group(1)) + keep = self.symbol in self.RE_NAME.findall(start.group(1)) continue if self.RE_ELSE.match(line): if block_start is None: diff --git a/tests/test_condition.py b/tests/test_condition.py index 68de308..d733796 100644 --- a/tests/test_condition.py +++ b/tests/test_condition.py @@ -98,6 +98,7 @@ def test_condition(md: markdown.Markdown, source: str, expected: str) -> None: 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"),