Skip to content

Commit 6ae597e

Browse files
committed
Merge commit '00acb3f3ec7ac769b6acbc117976d08db284c57b' into wb/t3
2 parents 4d8dfff + 00acb3f commit 6ae597e

3 files changed

Lines changed: 244 additions & 0 deletions

File tree

in2lambda/validation/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Pre-flight checks for the ``#``/``##`` and ``$``/``$$`` markdown delimiters.
2+
3+
The markdown that in2lambda converts - however it was produced - is a shared
4+
contract with Lambda Feedback. These checks catch structural mistakes in that
5+
markdown, currently unbalanced or misplaced math delimiters, before it is
6+
converted.
7+
"""
8+
9+
from in2lambda.validation.delimiters import MathDelimiterError, math_delimiter_checker
10+
11+
__all__ = ["MathDelimiterError", "math_delimiter_checker", "check_markdown"]
12+
13+
14+
def check_markdown(md_content: str) -> list[MathDelimiterError]:
15+
"""Run every markdown check and return the problems found.
16+
17+
Args:
18+
md_content: The markdown text to validate.
19+
20+
Returns:
21+
A list of :class:`MathDelimiterError` members, one per problem found.
22+
An empty list means the markdown passed every check.
23+
24+
Examples:
25+
>>> from in2lambda.validation import check_markdown
26+
>>> check_markdown("Inline $x = y$ is fine.")
27+
[]
28+
>>> check_markdown("Unbalanced $x = y")
29+
[<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>]
30+
"""
31+
problems: list[MathDelimiterError] = []
32+
33+
result = math_delimiter_checker(md_content)
34+
if result is not MathDelimiterError.PASSED:
35+
problems.append(result)
36+
37+
return problems

in2lambda/validation/delimiters.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly.
2+
3+
KaTeX (and Lambda Feedback) expect inline math wrapped in single dollar signs on
4+
one line, and display math wrapped in ``$$`` that each sit alone on their own
5+
line. This module scans markdown character by character and reports the first
6+
delimiter mistake it finds.
7+
"""
8+
9+
from enum import Enum
10+
11+
12+
class MathDelimiterError(Enum):
13+
"""Outcome of :func:`math_delimiter_checker`.
14+
15+
``PASSED`` means no problem was found; every other member describes a
16+
specific delimiter mistake. The value is a short human-readable message
17+
suitable for showing on the command line.
18+
"""
19+
20+
PASSED = "ok"
21+
MISSING_NEWLINE_BEFORE_OPENING_DISPLAY = "opening $$ must start its own line"
22+
MISSING_NEWLINE_AFTER_OPENING_DISPLAY = "opening $$ must be followed by a newline"
23+
DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE = "inline $ ... $ closed with $$"
24+
MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE = (
25+
"display $$ ... $$ closed with a single $"
26+
)
27+
MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY = "closing $$ must start its own line"
28+
MISSING_NEWLINE_AFTER_CLOSING_DISPLAY = "closing $$ must be followed by a newline"
29+
INVALID_NEWLINE_INSIDE_INLINE = "newline inside an inline $ ... $ expression"
30+
MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $"
31+
MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$"
32+
33+
34+
def math_delimiter_checker(md_content: str) -> MathDelimiterError:
35+
r"""Scan markdown for the first math-delimiter mistake.
36+
37+
``\$`` is treated as a literal dollar sign, not a delimiter.
38+
39+
Args:
40+
md_content: The markdown text to check.
41+
42+
Returns:
43+
``MathDelimiterError.PASSED`` if the delimiters are well formed,
44+
otherwise the member describing the first problem found.
45+
46+
Examples:
47+
>>> from in2lambda.validation.delimiters import math_delimiter_checker
48+
>>> math_delimiter_checker("An inline $x = y$ expression.")
49+
<MathDelimiterError.PASSED: 'ok'>
50+
>>> math_delimiter_checker("Display:\n$$\nx = y\n$$")
51+
<MathDelimiterError.PASSED: 'ok'>
52+
>>> math_delimiter_checker("This costs \\$5, no math here.")
53+
<MathDelimiterError.PASSED: 'ok'>
54+
>>> math_delimiter_checker("Broken $x = y")
55+
<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>
56+
"""
57+
# False once we are inside a math expression and awaiting its closing delimiter.
58+
expect_open_delimiter = True
59+
# While inside an expression, whether it opened with a single "$" (inline) or "$$" (display).
60+
expect_single_dollar = True
61+
62+
idx = 0
63+
while idx < len(md_content):
64+
prev_character = md_content[idx - 1] if idx > 0 else None
65+
character = md_content[idx]
66+
next_character = md_content[idx + 1] if idx + 1 < len(md_content) else None
67+
68+
if character == "$" and prev_character != "\\":
69+
if expect_open_delimiter:
70+
expect_open_delimiter = False
71+
72+
if next_character == "$":
73+
next_next_character = (
74+
md_content[idx + 2] if idx + 2 < len(md_content) else None
75+
)
76+
# "$$" must sit alone on its own line.
77+
if prev_character != "\n" and prev_character is not None:
78+
return MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY
79+
if next_next_character != "\n":
80+
return MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY
81+
82+
expect_single_dollar = False
83+
idx += 1 # Skip the second "$"; the loop increments idx again.
84+
else:
85+
expect_single_dollar = True
86+
else:
87+
expect_open_delimiter = True
88+
89+
if expect_single_dollar and next_character == "$":
90+
return MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE
91+
92+
elif not expect_single_dollar:
93+
if next_character != "$":
94+
return (
95+
MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE
96+
)
97+
98+
next_next_character = (
99+
md_content[idx + 2] if idx + 2 < len(md_content) else None
100+
)
101+
if prev_character != "\n" and prev_character is not None:
102+
return MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY
103+
if next_next_character != "\n" and next_next_character is not None:
104+
return MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY
105+
106+
idx += 1 # Skip the second "$"; the loop increments idx again.
107+
108+
# A newline may not appear inside an inline "$ ... $" expression.
109+
elif character == "\n" and not expect_open_delimiter and expect_single_dollar:
110+
return MathDelimiterError.INVALID_NEWLINE_INSIDE_INLINE
111+
112+
idx += 1
113+
114+
if expect_open_delimiter:
115+
return MathDelimiterError.PASSED
116+
elif expect_single_dollar:
117+
return MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR
118+
else:
119+
return MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR

tests/test_validation.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tests for the math-delimiter checker.
2+
3+
Ported from ``conversion2025/tools and testing/validator_tests.py`` on the
4+
``Summer2025`` branch and adapted to the :class:`MathDelimiterError` enum.
5+
"""
6+
7+
import pytest
8+
9+
from in2lambda.validation import (
10+
MathDelimiterError,
11+
check_markdown,
12+
math_delimiter_checker,
13+
)
14+
15+
E = MathDelimiterError
16+
17+
VALID = [
18+
"This is an inline math expression: $x = y$.",
19+
"This is an inline math expression: $x = y$",
20+
"$x = y$, this is an inline math expression.",
21+
"$x = y$\n",
22+
"\n$x = y$",
23+
"First expression $x = y$ and second expression $a = b$.",
24+
"Expression: $\\alpha + \\beta = \\gamma$.",
25+
"This is a display math expression:\n$$\nx = y\n$$",
26+
"$$\nx = y\n$$\n, this is a display math expression.",
27+
"Display math:\n$$\nx = y\n\na = b\n$$",
28+
"Expression:\n$$\n$$",
29+
"Inline $x = y$ and display math:\n$$\nx = y\n$$",
30+
"First:\n$$\nx = y\n$$\nSecond:\n$$\na = b\n$$",
31+
"",
32+
"This is just regular text with no math expressions.",
33+
"This costs \\$5 and that costs \\$10.",
34+
"Price is \\$10 and math is $x = y$.",
35+
"Price \\$100:\n$$\nx = y\n$$",
36+
"This symbol \\$\\$ is not math.",
37+
"\\$100 is expensive.",
38+
"It costs \\$",
39+
"Price \\$50 for $x + y = z$ calculation.",
40+
"Expression: $cost = \\$100$.",
41+
"Display:\n$$\ncost = \\$100\n$$",
42+
]
43+
44+
INVALID = [
45+
("This is an inline math expression: $x = y.", E.MISSING_CLOSING_SINGLE_DOLLAR),
46+
("This is an inline math expression: x = y$.", E.MISSING_CLOSING_SINGLE_DOLLAR),
47+
("This is an inline math expression:$x \n= y$.", E.INVALID_NEWLINE_INSIDE_INLINE),
48+
("This is an inline math expression:$\nx = y$.", E.INVALID_NEWLINE_INSIDE_INLINE),
49+
("This is an inline math expression:$x = y\n$.", E.INVALID_NEWLINE_INSIDE_INLINE),
50+
("Expression $x = y$$.", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
51+
("Expression $x = y$ and $a = b$ and $c =", E.MISSING_CLOSING_SINGLE_DOLLAR),
52+
("Expression $$$x = y$$$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
53+
(
54+
"This is a display math expression:\n$$\nx = y\n",
55+
E.MISSING_CLOSING_DOUBLE_DOLLAR,
56+
),
57+
(
58+
"This is a display math expression:\nx = y\n$$",
59+
E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY,
60+
),
61+
(
62+
"This is a display math expression:$$\nx = y\n$$.",
63+
E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY,
64+
),
65+
(
66+
"This is a display math expression:\n$$\nx = y\n$$.",
67+
E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY,
68+
),
69+
("Expression:\n$$text\nx = y\n$$", E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY),
70+
("Expression:\n$$\nx = y\ntext$$", E.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY),
71+
("Expression $$x = y$.", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
72+
("Expression $x = y$$", E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
73+
("Expression $$x = y$", E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
74+
]
75+
76+
77+
@pytest.mark.parametrize("content", VALID)
78+
def test_valid_markdown_passes(content: str) -> None:
79+
assert math_delimiter_checker(content) is E.PASSED
80+
assert check_markdown(content) == []
81+
82+
83+
@pytest.mark.parametrize("content, expected", INVALID)
84+
def test_invalid_markdown_is_reported(
85+
content: str, expected: MathDelimiterError
86+
) -> None:
87+
assert math_delimiter_checker(content) is expected
88+
assert check_markdown(content) == [expected]

0 commit comments

Comments
 (0)