Skip to content
Open
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
37 changes: 37 additions & 0 deletions in2lambda/validation/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
[<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>]
"""
problems: list[MathDelimiterError] = []

result = math_delimiter_checker(md_content)
if result is not MathDelimiterError.PASSED:
problems.append(result)

return problems
119 changes: 119 additions & 0 deletions in2lambda/validation/delimiters.py
Original file line number Diff line number Diff line change
@@ -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.")
<MathDelimiterError.PASSED: 'ok'>
>>> math_delimiter_checker("Display:\n$$\nx = y\n$$")
<MathDelimiterError.PASSED: 'ok'>
>>> math_delimiter_checker("This costs \\$5, no math here.")
<MathDelimiterError.PASSED: 'ok'>
>>> math_delimiter_checker("Broken $x = y")
<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>
"""
# 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
88 changes: 88 additions & 0 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
@@ -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]
Loading