diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py new file mode 100644 index 0000000..b4400c0 --- /dev/null +++ b/in2lambda/validation/__init__.py @@ -0,0 +1,40 @@ +"""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, + MathDelimiterProblem, + math_delimiter_checker, +) + +__all__ = [ + "MathDelimiterError", + "MathDelimiterProblem", + "math_delimiter_checker", + "check_markdown", +] + + +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:`MathDelimiterProblem`, 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") + [MathDelimiterProblem(line=1, error=)] + """ + return math_delimiter_checker(md_content) diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py new file mode 100644 index 0000000..10b8014 --- /dev/null +++ b/in2lambda/validation/delimiters.py @@ -0,0 +1,199 @@ +"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. + +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 enum import Enum + + +class MathDelimiterError(Enum): + """A specific delimiter mistake found by :func:`math_delimiter_checker`. + + The value is a short human-readable message suitable for showing on the + command line. + """ + + 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" + MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $" + MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$" + + +@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: + """Render as ``line : ``.""" + 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. + + Args: + md_content: The markdown text to check. + + Returns: + 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("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. + 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 + open_line = md_content.count("\n", 0, idx) + 1 + + 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: + 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 + else: + expect_open_delimiter = True + + if expect_single_dollar and next_character == "$": + report(MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE) + expect_open_delimiter, expect_single_dollar = True, True + + elif not expect_single_dollar: + if next_character != "$": + report( + MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE + ) + 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 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 new file mode 100644 index 0000000..698309e --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,157 @@ +"""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, + MathDelimiterProblem, + check_markdown, + math_delimiter_checker, +) + +E = MathDelimiterError +P = MathDelimiterProblem + +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$$", + # 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.", + [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", + [P(2, E.MISSING_CLOSING_DOUBLE_DOLLAR)], + ), + ( + "This is a display math expression:\nx = y\n$$", + [P(3, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY)], + ), + ( + "This is a display math expression:$$\nx = y\n$$.", + [ + 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$$.", + [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), + ], + ), +] + + +@pytest.mark.parametrize("content", VALID) +def test_valid_markdown_passes(content: str) -> None: + assert math_delimiter_checker(content) == [] + assert check_markdown(content) == [] + + +@pytest.mark.parametrize("content, expected", INVALID) +def test_invalid_markdown_is_reported( + content: str, expected: list[MathDelimiterProblem] +) -> None: + assert math_delimiter_checker(content) == expected + assert check_markdown(content) == expected