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]