Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ not RELEASE
<p>not RELEASE</p>
```

### Rules

- `<!--- #if A B -->` 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.
Expand Down
3 changes: 2 additions & 1 deletion md_condition/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
83 changes: 46 additions & 37 deletions md_condition/preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<!--- #if .* -->")
RE_START = re.compile(r"<!--- #if (.*?) -->")
RE_END = re.compile(r"<!--- #endif -->")
RE_ELSE = re.compile(r"<!--- #else -->")
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"<!--- #if .*" + symbol + 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
65 changes: 61 additions & 4 deletions tests/test_condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import markdown
import pytest

from md_condition import ConditionExtension
from md_condition import ConditionExtension, ConditionSyntaxError


@pytest.fixture
Expand Down Expand Up @@ -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"<!--- #if {condition} -->\nyes\n<!--- #else -->\nno\n<!--- #endif -->")
assert output == ("<p>yes</p>" if kept else "<p>no</p>")


@pytest.mark.parametrize(
("source", "message"),
[
pytest.param(
"""\
<!--- #if DEBUG RELEASE -->
# 1""",
"line 1: #if is never closed",
id="missing_endif",
),
pytest.param(
Expand All @@ -103,15 +128,47 @@ def test_condition(md: markdown.Markdown, source: str, expected: str) -> None:
# 1
<!--- #endif -->
<!--- #endif -->""",
"line 4: #endif without #if",
id="extra_endif",
),
pytest.param(
"""\
# 1
<!--- #else -->""",
"line 2: #else without #if",
id="else_without_if",
),
pytest.param(
"""\
<!--- #if DEBUG -->
<!--- #else -->
<!--- #else -->
<!--- #endif -->""",
"line 3: duplicate #else",
id="duplicate_else",
),
pytest.param(
"""\
<!--- #if DEBUG -->
<!--- #if RELEASE -->
<!--- #endif -->
<!--- #endif -->""",
"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, "<!--- #if DEBUG -->")


def test_make_extension_by_name() -> None:
md = markdown.Markdown(
extensions=["md_condition"],
Expand Down
Loading