From 4627487fdd31e078c407ab8e52c8ab41d967563a Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 11:03:30 +0100 Subject: [PATCH 1/6] test: add pytest suite alongside doctests Adds a real tests/ suite so the project no longer relies on doctests alone: - tests/test_runner.py runs each built-in filter over its own example.tex end to end, asserting on the returned Set and on the JSON/ZIP written to disk. - [tool.pytest.ini_options] collects both tests/ and the package doctests, so a bare `pytest` covers everything. - CI: `black .` -> `black --check .` (no longer silently reformats), and isort/pydocstyle now also cover tests/. Applies black to two pre-existing files (visibility_status.py, json_convert.py) that were not clean under `black --check`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r --- .github/workflows/test.yml | 8 ++-- in2lambda/api/visibility_status.py | 1 + in2lambda/json_convert/json_convert.py | 5 ++- pyproject.toml | 5 +++ tests/conftest.py | 16 ++++++++ tests/test_runner.py | 57 ++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_runner.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0288e3..d2e798f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,11 +24,11 @@ jobs: uses: r-lib/actions/setup-pandoc@v2 - name: Linting Checks run: | - poetry run black . - poetry run isort --check-only in2lambda docs - poetry run pydocstyle --convention=google in2lambda + poetry run black --check . + poetry run isort --check-only in2lambda docs tests + poetry run pydocstyle --convention=google in2lambda tests - name: pytest - run: poetry run pytest --cov-report=xml:coverage.xml --cov=in2lambda --doctest-modules in2lambda + run: poetry run pytest --cov-report=xml:coverage.xml --cov=in2lambda - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/in2lambda/api/visibility_status.py b/in2lambda/api/visibility_status.py index 541c97d..c0295dd 100644 --- a/in2lambda/api/visibility_status.py +++ b/in2lambda/api/visibility_status.py @@ -2,6 +2,7 @@ from enum import Enum + class VisibilityStatus(Enum): """Enum representing the visibility status of a question or set.""" diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 73b49eb..dd85f23 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -98,7 +98,10 @@ def converter( # Output file filename = ( - "question_" + str(i).zfill(3) + "_" + re.sub(r'[^\w\-_.]', '_', output['title'].strip()) + "question_" + + str(i).zfill(3) + + "_" + + re.sub(r"[^\w\-_.]", "_", output["title"].strip()) ) # write questions into directory diff --git a/pyproject.toml b/pyproject.toml index 7929d25..e1b4022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,11 @@ ignore_missing_imports = true [tool.isort] profile = "black" +[tool.pytest.ini_options] +# Collect both the unit tests in tests/ and the doctests embedded in the package. +testpaths = ["tests", "in2lambda"] +addopts = "--doctest-modules" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..095b418 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Shared pytest fixtures for the in2lambda test suite.""" + +import os + +import pytest + +import in2lambda + + +@pytest.fixture(scope="session") +def filters_dir() -> str: + """Absolute path to the packaged ``filters`` directory. + + Each filter ships a self-contained ``example.tex`` used by the end-to-end tests. + """ + return os.path.join(os.path.dirname(in2lambda.__file__), "filters") diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..df31429 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,57 @@ +"""End-to-end tests for :func:`in2lambda.main.runner` across the built-in filters. + +Each built-in filter ships a self-contained ``example.tex`` that exercises the +document structure it targets. These tests run every filter over its own example +and check both the in-memory :class:`~in2lambda.api.set.Set` and the JSON/ZIP +files written to disk. +""" + +import json +import os + +import pytest + +from in2lambda.api.set import Set +from in2lambda.main import runner + +BUILTIN_FILTERS = ["PartsSepSol", "PartsOneSol", "PartPartSolSol", "PartSolPartSol"] + + +@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) +def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> None: + """Every filter turns its example into a Set with at least one usable question.""" + result = runner(os.path.join(filters_dir, filter_name, "example.tex"), filter_name) + + assert isinstance(result, Set) + assert result.questions, f"{filter_name} produced no questions" + for question in result.questions: + # A question is only useful if it has top-level text or at least one part. + assert question.main_text or question.parts + + +@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) +def test_runner_writes_importable_json( + filter_name: str, filters_dir: str, tmp_path +) -> None: + """Passing an output directory produces the Lambda Feedback set/ dir and zip.""" + out_dir = tmp_path / "out" + result = runner( + os.path.join(filters_dir, filter_name, "example.tex"), + filter_name, + str(out_dir), + ) + + set_dir = out_dir / "set" + assert set_dir.is_dir() + assert (out_dir / "set.zip").is_file() + + set_json = json.loads((set_dir / "set_set.json").read_text()) + assert set_json["name"] == "set" + + question_files = sorted(set_dir.glob("question_*.json")) + assert len(question_files) == len(result.questions) + for question_file in question_files: + question_json = json.loads(question_file.read_text()) + assert question_json["title"] + assert "masterContent" in question_json + assert "parts" in question_json From 0fb70b0d9fc601c05a712537a565560983094979 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 11:15:19 +0100 Subject: [PATCH 2/6] feat: add math-delimiter validator New in2lambda.validation package that checks the #/## markdown contract for unbalanced or misplaced KaTeX math delimiters before conversion: - validation/delimiters.py: math_delimiter_checker() scans markdown char by char and returns a MathDelimiterError enum member (PASSED on success). - validation/__init__.py: check_markdown() aggregates checks into a list of problems, ready to be surfaced as warnings by the Markdown filter and the wizard. Ported from conversion2025/tools and testing/{validator,validator_classes}.py on the Summer2025 branch; the sentinel exception classes are replaced with an enum. All of that branch's validator_tests.py cases are ported to tests/test_validation.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r --- in2lambda/validation/__init__.py | 37 +++++++++ in2lambda/validation/delimiters.py | 119 +++++++++++++++++++++++++++++ tests/test_validation.py | 88 +++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 in2lambda/validation/__init__.py create mode 100644 in2lambda/validation/delimiters.py create mode 100644 tests/test_validation.py diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py new file mode 100644 index 0000000..2cdfba6 --- /dev/null +++ b/in2lambda/validation/__init__.py @@ -0,0 +1,37 @@ +"""Pre-flight checks for the ``#``/``##`` markdown that flows through in2lambda. + +The markdown produced by the wizard (and hand-written by users) is the shared +contract between the wizard, the ``Markdown`` filter and Lambda Feedback. These +checks catch structural mistakes - currently unbalanced/misplaced math +delimiters - before the markdown is converted. +""" + +from in2lambda.validation.delimiters import MathDelimiterError, math_delimiter_checker + +__all__ = ["MathDelimiterError", "math_delimiter_checker", "check_markdown"] + + +def check_markdown(md_content: str) -> list[MathDelimiterError]: + """Run every markdown check and return the problems found. + + Args: + md_content: The markdown text to validate. + + Returns: + A list of :class:`MathDelimiterError` members, one per problem found. + An empty list means the markdown passed every check. + + Examples: + >>> from in2lambda.validation import check_markdown + >>> check_markdown("Inline $x = y$ is fine.") + [] + >>> check_markdown("Unbalanced $x = y") + [] + """ + problems: list[MathDelimiterError] = [] + + result = math_delimiter_checker(md_content) + if result is not MathDelimiterError.PASSED: + problems.append(result) + + return problems diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py new file mode 100644 index 0000000..6824b99 --- /dev/null +++ b/in2lambda/validation/delimiters.py @@ -0,0 +1,119 @@ +"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. + +KaTeX (and Lambda Feedback) expect inline math wrapped in single dollar signs on +one line, and display math wrapped in ``$$`` that each sit alone on their own +line. This module scans markdown character by character and reports the first +delimiter mistake it finds. +""" + +from enum import Enum + + +class MathDelimiterError(Enum): + """Outcome of :func:`math_delimiter_checker`. + + ``PASSED`` means no problem was found; every other member describes a + specific delimiter mistake. The value is a short human-readable message + suitable for showing on the command line. + """ + + PASSED = "ok" + MISSING_NEWLINE_BEFORE_OPENING_DISPLAY = "opening $$ must start its own line" + MISSING_NEWLINE_AFTER_OPENING_DISPLAY = "opening $$ must be followed by a newline" + DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE = "inline $ ... $ closed with $$" + MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE = ( + "display $$ ... $$ closed with a single $" + ) + MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY = "closing $$ must start its own line" + MISSING_NEWLINE_AFTER_CLOSING_DISPLAY = "closing $$ must be followed by a newline" + INVALID_NEWLINE_INSIDE_INLINE = "newline inside an inline $ ... $ expression" + MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $" + MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$" + + +def math_delimiter_checker(md_content: str) -> MathDelimiterError: + r"""Scan markdown for the first math-delimiter mistake. + + ``\$`` is treated as a literal dollar sign, not a delimiter. + + Args: + md_content: The markdown text to check. + + Returns: + ``MathDelimiterError.PASSED`` if the delimiters are well formed, + otherwise the member describing the first problem found. + + Examples: + >>> from in2lambda.validation.delimiters import math_delimiter_checker + >>> math_delimiter_checker("An inline $x = y$ expression.") + + >>> math_delimiter_checker("Display:\n$$\nx = y\n$$") + + >>> math_delimiter_checker("This costs \\$5, no math here.") + + >>> math_delimiter_checker("Broken $x = y") + + """ + # False once we are inside a math expression and awaiting its closing delimiter. + expect_open_delimiter = True + # While inside an expression, whether it opened with a single "$" (inline) or "$$" (display). + expect_single_dollar = True + + idx = 0 + while idx < len(md_content): + prev_character = md_content[idx - 1] if idx > 0 else None + character = md_content[idx] + next_character = md_content[idx + 1] if idx + 1 < len(md_content) else None + + if character == "$" and prev_character != "\\": + if expect_open_delimiter: + expect_open_delimiter = False + + if next_character == "$": + next_next_character = ( + md_content[idx + 2] if idx + 2 < len(md_content) else None + ) + # "$$" must sit alone on its own line. + if prev_character != "\n" and prev_character is not None: + return MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY + if next_next_character != "\n": + return MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY + + expect_single_dollar = False + idx += 1 # Skip the second "$"; the loop increments idx again. + else: + expect_single_dollar = True + else: + expect_open_delimiter = True + + if expect_single_dollar and next_character == "$": + return MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE + + elif not expect_single_dollar: + if next_character != "$": + return ( + MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE + ) + + next_next_character = ( + md_content[idx + 2] if idx + 2 < len(md_content) else None + ) + if prev_character != "\n" and prev_character is not None: + return MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY + if next_next_character != "\n" and next_next_character is not None: + return MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY + + idx += 1 # Skip the second "$"; the loop increments idx again. + + # A newline may not appear inside an inline "$ ... $" expression. + elif character == "\n" and not expect_open_delimiter and expect_single_dollar: + return MathDelimiterError.INVALID_NEWLINE_INSIDE_INLINE + + idx += 1 + + if expect_open_delimiter: + return MathDelimiterError.PASSED + elif expect_single_dollar: + return MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR + else: + return MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..e624e4a --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,88 @@ +"""Tests for the math-delimiter checker. + +Ported from ``conversion2025/tools and testing/validator_tests.py`` on the +``Summer2025`` branch and adapted to the :class:`MathDelimiterError` enum. +""" + +import pytest + +from in2lambda.validation import ( + MathDelimiterError, + check_markdown, + math_delimiter_checker, +) + +E = MathDelimiterError + +VALID = [ + "This is an inline math expression: $x = y$.", + "This is an inline math expression: $x = y$", + "$x = y$, this is an inline math expression.", + "$x = y$\n", + "\n$x = y$", + "First expression $x = y$ and second expression $a = b$.", + "Expression: $\\alpha + \\beta = \\gamma$.", + "This is a display math expression:\n$$\nx = y\n$$", + "$$\nx = y\n$$\n, this is a display math expression.", + "Display math:\n$$\nx = y\n\na = b\n$$", + "Expression:\n$$\n$$", + "Inline $x = y$ and display math:\n$$\nx = y\n$$", + "First:\n$$\nx = y\n$$\nSecond:\n$$\na = b\n$$", + "", + "This is just regular text with no math expressions.", + "This costs \\$5 and that costs \\$10.", + "Price is \\$10 and math is $x = y$.", + "Price \\$100:\n$$\nx = y\n$$", + "This symbol \\$\\$ is not math.", + "\\$100 is expensive.", + "It costs \\$", + "Price \\$50 for $x + y = z$ calculation.", + "Expression: $cost = \\$100$.", + "Display:\n$$\ncost = \\$100\n$$", +] + +INVALID = [ + ("This is an inline math expression: $x = y.", E.MISSING_CLOSING_SINGLE_DOLLAR), + ("This is an inline math expression: x = y$.", E.MISSING_CLOSING_SINGLE_DOLLAR), + ("This is an inline math expression:$x \n= y$.", E.INVALID_NEWLINE_INSIDE_INLINE), + ("This is an inline math expression:$\nx = y$.", E.INVALID_NEWLINE_INSIDE_INLINE), + ("This is an inline math expression:$x = y\n$.", E.INVALID_NEWLINE_INSIDE_INLINE), + ("Expression $x = y$$.", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + ("Expression $x = y$ and $a = b$ and $c =", E.MISSING_CLOSING_SINGLE_DOLLAR), + ("Expression $$$x = y$$$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + ( + "This is a display math expression:\n$$\nx = y\n", + E.MISSING_CLOSING_DOUBLE_DOLLAR, + ), + ( + "This is a display math expression:\nx = y\n$$", + E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY, + ), + ( + "This is a display math expression:$$\nx = y\n$$.", + E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY, + ), + ( + "This is a display math expression:\n$$\nx = y\n$$.", + E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY, + ), + ("Expression:\n$$text\nx = y\n$$", E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY), + ("Expression:\n$$\nx = y\ntext$$", E.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY), + ("Expression $$x = y$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + ("Expression $x = y$$", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + ("Expression $$x = y$", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), +] + + +@pytest.mark.parametrize("content", VALID) +def test_valid_markdown_passes(content: str) -> None: + assert math_delimiter_checker(content) is E.PASSED + assert check_markdown(content) == [] + + +@pytest.mark.parametrize("content, expected", INVALID) +def test_invalid_markdown_is_reported( + content: str, expected: MathDelimiterError +) -> None: + assert math_delimiter_checker(content) is expected + assert check_markdown(content) == [expected] From 929c9dace31156f0d0d9c87e32429a0d2f4cac71 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 14:33:50 +0100 Subject: [PATCH 3/6] fix: skip backtick code spans/fences when scanning math delimiters A `$` inside inline code (`echo $PATH`) or a fenced code block was previously treated as a math delimiter, misreporting shell variables and code samples as broken math. Track fence/code-span state in the scanner and skip delimiter checks while inside one. Co-Authored-By: Claude Sonnet 5 --- in2lambda/validation/delimiters.py | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index 6824b99..e0694a5 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -53,18 +53,59 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: >>> math_delimiter_checker("Broken $x = y") + >>> math_delimiter_checker("Run `echo $PATH` now.") + """ # False once we are inside a math expression and awaiting its closing delimiter. expect_open_delimiter = True # While inside an expression, whether it opened with a single "$" (inline) or "$$" (display). expect_single_dollar = True + # Backtick code spans/fences are not markdown math and must not be scanned for + # "$" delimiters, e.g. a shell variable like `echo $PATH` or a fenced snippet. + in_fence = False + fence_marker_len = 0 + in_code_span = False + code_span_marker_len = 0 + idx = 0 while idx < len(md_content): prev_character = md_content[idx - 1] if idx > 0 else None character = md_content[idx] next_character = md_content[idx + 1] if idx + 1 < len(md_content) else None + if character == "`" and prev_character != "`": + run_len = 0 + while idx + run_len < len(md_content) and md_content[idx + run_len] == "`": + run_len += 1 + line_start = md_content.rfind("\n", 0, idx) + 1 + at_line_start = md_content[line_start:idx].strip() == "" + + if in_fence: + line_end = md_content.find("\n", idx + run_len) + if line_end == -1: + line_end = len(md_content) + if ( + run_len >= fence_marker_len + and at_line_start + and md_content[idx + run_len : line_end].strip() == "" + ): + in_fence, fence_marker_len = False, 0 + elif in_code_span: + if run_len == code_span_marker_len: + in_code_span, code_span_marker_len = False, 0 + elif run_len >= 3 and at_line_start: + in_fence, fence_marker_len = True, run_len + else: + in_code_span, code_span_marker_len = True, run_len + + idx += 1 + continue + + if in_fence or in_code_span: + idx += 1 + continue + if character == "$" and prev_character != "\\": if expect_open_delimiter: expect_open_delimiter = False From f08f4df5a3b47c96e249d72e9aa287c125527432 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 14:45:29 +0100 Subject: [PATCH 4/6] fix: report every math-delimiter problem with its line number Previously the checker returned a single MathDelimiterError enum for the whole document, stopping at the first problem, and misreported an unclosed inline "$" as INVALID_NEWLINE_INSIDE_INLINE whenever a newline appeared before end of input. math_delimiter_checker/check_markdown now return list[MathDelimiterProblem], each carrying a 1-based line number, and resync after an error to keep scanning for further independent problems instead of stopping at the first. The newline-inside-inline check is removed entirely: an unclosed "$ ... $" is always reported as MISSING_CLOSING_SINGLE_DOLLAR regardless of embedded newlines, and (as a consequence) a multi-line "$ ... $" that does eventually close is no longer flagged as an error. PASSED is dropped from the enum since "no problem" is now represented by an empty list. Co-Authored-By: Claude Sonnet 5 --- in2lambda/validation/__init__.py | 27 ++++--- in2lambda/validation/delimiters.py | 122 +++++++++++++++++++---------- tests/test_validation.py | 111 +++++++++++++++++++++----- 3 files changed, 184 insertions(+), 76 deletions(-) diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py index 2cdfba6..b4400c0 100644 --- a/in2lambda/validation/__init__.py +++ b/in2lambda/validation/__init__.py @@ -6,19 +6,28 @@ delimiters - before the markdown is converted. """ -from in2lambda.validation.delimiters import MathDelimiterError, math_delimiter_checker +from in2lambda.validation.delimiters import ( + MathDelimiterError, + MathDelimiterProblem, + math_delimiter_checker, +) -__all__ = ["MathDelimiterError", "math_delimiter_checker", "check_markdown"] +__all__ = [ + "MathDelimiterError", + "MathDelimiterProblem", + "math_delimiter_checker", + "check_markdown", +] -def check_markdown(md_content: str) -> list[MathDelimiterError]: +def check_markdown(md_content: str) -> list[MathDelimiterProblem]: """Run every markdown check and return the problems found. Args: md_content: The markdown text to validate. Returns: - A list of :class:`MathDelimiterError` members, one per problem found. + A list of :class:`MathDelimiterProblem`, one per problem found. An empty list means the markdown passed every check. Examples: @@ -26,12 +35,6 @@ def check_markdown(md_content: str) -> list[MathDelimiterError]: >>> check_markdown("Inline $x = y$ is fine.") [] >>> check_markdown("Unbalanced $x = y") - [] + [MathDelimiterProblem(line=1, error=)] """ - problems: list[MathDelimiterError] = [] - - result = math_delimiter_checker(md_content) - if result is not MathDelimiterError.PASSED: - problems.append(result) - - return problems + return math_delimiter_checker(md_content) diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index e0694a5..a9b3f74 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -6,18 +6,17 @@ delimiter mistake it finds. """ +from dataclasses import dataclass from enum import Enum class MathDelimiterError(Enum): - """Outcome of :func:`math_delimiter_checker`. + """A specific delimiter mistake found by :func:`math_delimiter_checker`. - ``PASSED`` means no problem was found; every other member describes a - specific delimiter mistake. The value is a short human-readable message - suitable for showing on the command line. + The value is a short human-readable message suitable for showing on the + command line. """ - PASSED = "ok" MISSING_NEWLINE_BEFORE_OPENING_DISPLAY = "opening $$ must start its own line" MISSING_NEWLINE_AFTER_OPENING_DISPLAY = "opening $$ must be followed by a newline" DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE = "inline $ ... $ closed with $$" @@ -26,13 +25,23 @@ class MathDelimiterError(Enum): ) MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY = "closing $$ must start its own line" MISSING_NEWLINE_AFTER_CLOSING_DISPLAY = "closing $$ must be followed by a newline" - INVALID_NEWLINE_INSIDE_INLINE = "newline inside an inline $ ... $ expression" MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $" MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$" -def math_delimiter_checker(md_content: str) -> MathDelimiterError: - r"""Scan markdown for the first math-delimiter mistake. +@dataclass(frozen=True) +class MathDelimiterProblem: + """A single delimiter mistake and the (1-based) line it was found on.""" + + line: int + error: MathDelimiterError + + def __str__(self) -> str: + return f"line {self.line}: {self.error.value}" + + +def math_delimiter_checker(md_content: str) -> list[MathDelimiterProblem]: + r"""Scan markdown for every math-delimiter mistake. ``\$`` is treated as a literal dollar sign, not a delimiter. @@ -40,26 +49,34 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: md_content: The markdown text to check. Returns: - ``MathDelimiterError.PASSED`` if the delimiters are well formed, - otherwise the member describing the first problem found. + A list of :class:`MathDelimiterProblem`, one per mistake found, in + the order they occur. An empty list means the delimiters are well + formed. Examples: >>> from in2lambda.validation.delimiters import math_delimiter_checker >>> math_delimiter_checker("An inline $x = y$ expression.") - + [] >>> math_delimiter_checker("Display:\n$$\nx = y\n$$") - + [] >>> math_delimiter_checker("This costs \\$5, no math here.") - - >>> math_delimiter_checker("Broken $x = y") - + [] >>> math_delimiter_checker("Run `echo $PATH` now.") - + [] + >>> math_delimiter_checker("Broken $x = y") + [MathDelimiterProblem(line=1, error=)] """ + problems: list[MathDelimiterProblem] = [] + + def report(error: MathDelimiterError) -> None: + problems.append(MathDelimiterProblem(md_content.count("\n", 0, idx) + 1, error)) + # False once we are inside a math expression and awaiting its closing delimiter. expect_open_delimiter = True # While inside an expression, whether it opened with a single "$" (inline) or "$$" (display). expect_single_dollar = True + # Line on which the currently open (unclosed) expression started. + open_line = 1 # Backtick code spans/fences are not markdown math and must not be scanned for # "$" delimiters, e.g. a shell variable like `echo $PATH` or a fenced snippet. @@ -109,6 +126,7 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: if character == "$" and prev_character != "\\": if expect_open_delimiter: expect_open_delimiter = False + open_line = md_content.count("\n", 0, idx) + 1 if next_character == "$": next_next_character = ( @@ -116,11 +134,15 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: ) # "$$" must sit alone on its own line. if prev_character != "\n" and prev_character is not None: - return MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY - if next_next_character != "\n": - return MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY - - expect_single_dollar = False + report( + MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY + ) + expect_open_delimiter, expect_single_dollar = True, True + elif next_next_character != "\n": + report(MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY) + expect_open_delimiter, expect_single_dollar = True, True + else: + expect_single_dollar = False idx += 1 # Skip the second "$"; the loop increments idx again. else: expect_single_dollar = True @@ -128,33 +150,47 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: expect_open_delimiter = True if expect_single_dollar and next_character == "$": - return MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE + report(MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE) + expect_open_delimiter, expect_single_dollar = True, True elif not expect_single_dollar: if next_character != "$": - return ( + report( MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE ) - - next_next_character = ( - md_content[idx + 2] if idx + 2 < len(md_content) else None - ) - if prev_character != "\n" and prev_character is not None: - return MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY - if next_next_character != "\n" and next_next_character is not None: - return MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY - - idx += 1 # Skip the second "$"; the loop increments idx again. - - # A newline may not appear inside an inline "$ ... $" expression. - elif character == "\n" and not expect_open_delimiter and expect_single_dollar: - return MathDelimiterError.INVALID_NEWLINE_INSIDE_INLINE + expect_open_delimiter, expect_single_dollar = True, True + else: + next_next_character = ( + md_content[idx + 2] if idx + 2 < len(md_content) else None + ) + if prev_character != "\n" and prev_character is not None: + report( + MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY + ) + expect_open_delimiter, expect_single_dollar = True, True + elif ( + next_next_character != "\n" + and next_next_character is not None + ): + report( + MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY + ) + expect_open_delimiter, expect_single_dollar = True, True + + idx += 1 # Skip the second "$"; the loop increments idx again. idx += 1 - if expect_open_delimiter: - return MathDelimiterError.PASSED - elif expect_single_dollar: - return MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR - else: - return MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR + if not expect_open_delimiter: + problems.append( + MathDelimiterProblem( + open_line, + ( + MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR + if expect_single_dollar + else MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR + ), + ) + ) + + return problems diff --git a/tests/test_validation.py b/tests/test_validation.py index e624e4a..698309e 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -8,11 +8,13 @@ from in2lambda.validation import ( MathDelimiterError, + MathDelimiterProblem, check_markdown, math_delimiter_checker, ) E = MathDelimiterError +P = MathDelimiterProblem VALID = [ "This is an inline math expression: $x = y$.", @@ -39,50 +41,117 @@ "Price \\$50 for $x + y = z$ calculation.", "Expression: $cost = \\$100$.", "Display:\n$$\ncost = \\$100\n$$", + # A "$ ... $" expression may now span a newline before closing. + "This is an inline math expression:$x \n= y$.", + "This is an inline math expression:$\nx = y$.", + "This is an inline math expression:$x = y\n$.", + # A "$" inside a code span/fence is not math and must be ignored. + "Run `echo $PATH` now.\n", + "```bash\necho $HOME\n```\n", ] INVALID = [ - ("This is an inline math expression: $x = y.", E.MISSING_CLOSING_SINGLE_DOLLAR), - ("This is an inline math expression: x = y$.", E.MISSING_CLOSING_SINGLE_DOLLAR), - ("This is an inline math expression:$x \n= y$.", E.INVALID_NEWLINE_INSIDE_INLINE), - ("This is an inline math expression:$\nx = y$.", E.INVALID_NEWLINE_INSIDE_INLINE), - ("This is an inline math expression:$x = y\n$.", E.INVALID_NEWLINE_INSIDE_INLINE), - ("Expression $x = y$$.", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), - ("Expression $x = y$ and $a = b$ and $c =", E.MISSING_CLOSING_SINGLE_DOLLAR), - ("Expression $$$x = y$$$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + ( + "This is an inline math expression: $x = y.", + [P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)], + ), + ( + "This is an inline math expression: x = y$.", + [P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)], + ), + ( + "Expression $x = y$$.", + [ + P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + P(1, E.MISSING_CLOSING_SINGLE_DOLLAR), + ], + ), + ( + "Expression $x = y$ and $a = b$ and $c =", + [P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)], + ), + ( + "Expression $$$x = y$$$.", + [ + P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + ], + ), ( "This is a display math expression:\n$$\nx = y\n", - E.MISSING_CLOSING_DOUBLE_DOLLAR, + [P(2, E.MISSING_CLOSING_DOUBLE_DOLLAR)], ), ( "This is a display math expression:\nx = y\n$$", - E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY, + [P(3, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY)], ), ( "This is a display math expression:$$\nx = y\n$$.", - E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY, + [ + P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + P(3, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY), + ], ), ( "This is a display math expression:\n$$\nx = y\n$$.", - E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY, + [P(4, E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY)], + ), + ( + "Expression:\n$$text\nx = y\n$$", + [ + P(2, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY), + P(4, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY), + ], + ), + ( + "Expression:\n$$\nx = y\ntext$$", + [P(4, E.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY)], + ), + ( + "Expression $$x = y$.", + [ + P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + P(1, E.MISSING_CLOSING_SINGLE_DOLLAR), + ], + ), + ( + "Expression $x = y$$", + [ + P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + P(1, E.MISSING_CLOSING_SINGLE_DOLLAR), + ], + ), + ( + "Expression $$x = y$", + [ + P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + P(1, E.MISSING_CLOSING_SINGLE_DOLLAR), + ], + ), + # An unclosed "$" must report MISSING_CLOSING_SINGLE_DOLLAR, not a + # newline-related error, however many lines it spans before EOF. + ("This $x is unclosed.\n", [P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)]), + # Two independent problems on different lines are both reported. + ( + "Expression $x = y$$.\nAnother $a = b$$.\n", + [ + P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), + P(2, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), + ], ), - ("Expression:\n$$text\nx = y\n$$", E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY), - ("Expression:\n$$\nx = y\ntext$$", E.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY), - ("Expression $$x = y$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), - ("Expression $x = y$$", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE), - ("Expression $$x = y$", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY), ] @pytest.mark.parametrize("content", VALID) def test_valid_markdown_passes(content: str) -> None: - assert math_delimiter_checker(content) is E.PASSED + assert math_delimiter_checker(content) == [] assert check_markdown(content) == [] @pytest.mark.parametrize("content, expected", INVALID) def test_invalid_markdown_is_reported( - content: str, expected: MathDelimiterError + content: str, expected: list[MathDelimiterProblem] ) -> None: - assert math_delimiter_checker(content) is expected - assert check_markdown(content) == [expected] + assert math_delimiter_checker(content) == expected + assert check_markdown(content) == expected From 955654f312d0ce9fd1da81ffbcb6563b144bac55 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 14:46:05 +0100 Subject: [PATCH 5/6] docs: clarify $$-on-its-own-line is our convention, not a KaTeX rule The module docstring attributed this formatting rule to KaTeX itself; it's actually just this project's authoring convention. Co-Authored-By: Claude Sonnet 5 --- in2lambda/validation/delimiters.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index a9b3f74..36d73f5 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -1,9 +1,11 @@ """Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. -KaTeX (and Lambda Feedback) expect inline math wrapped in single dollar signs on -one line, and display math wrapped in ``$$`` that each sit alone on their own -line. This module scans markdown character by character and reports the first -delimiter mistake it finds. +Lambda Feedback renders inline math (via KaTeX) wrapped in single dollar +signs, and display math wrapped in ``$$``. Requiring each ``$$`` delimiter to +sit alone on its own line is this project's own authoring convention, not a +KaTeX requirement. This module scans markdown character by character, +skipping fenced and inline code, and reports every delimiter mistake it +finds together with the line it occurred on. """ from dataclasses import dataclass From 54029d5bfaed3470c60babd99f7ad3a869fb06c8 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 15:06:05 +0100 Subject: [PATCH 6/6] fix: add missing docstring on MathDelimiterProblem.__str__ pydocstyle (D105) was failing CI lint for the magic method missing a docstring. Co-Authored-By: Claude Sonnet 5 --- in2lambda/validation/delimiters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index 36d73f5..10b8014 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -39,6 +39,7 @@ class MathDelimiterProblem: error: MathDelimiterError def __str__(self) -> str: + """Render as ``line : ``.""" return f"line {self.line}: {self.error.value}"