Skip to content

Commit f08f4df

Browse files
m-messerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 929c9da commit f08f4df

3 files changed

Lines changed: 184 additions & 76 deletions

File tree

‎in2lambda/validation/__init__.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,35 @@
66
delimiters - before the markdown is converted.
77
"""
88

9-
from in2lambda.validation.delimiters import MathDelimiterError, math_delimiter_checker
9+
from in2lambda.validation.delimiters import (
10+
MathDelimiterError,
11+
MathDelimiterProblem,
12+
math_delimiter_checker,
13+
)
1014

11-
__all__ = ["MathDelimiterError", "math_delimiter_checker", "check_markdown"]
15+
__all__ = [
16+
"MathDelimiterError",
17+
"MathDelimiterProblem",
18+
"math_delimiter_checker",
19+
"check_markdown",
20+
]
1221

1322

14-
def check_markdown(md_content: str) -> list[MathDelimiterError]:
23+
def check_markdown(md_content: str) -> list[MathDelimiterProblem]:
1524
"""Run every markdown check and return the problems found.
1625
1726
Args:
1827
md_content: The markdown text to validate.
1928
2029
Returns:
21-
A list of :class:`MathDelimiterError` members, one per problem found.
30+
A list of :class:`MathDelimiterProblem`, one per problem found.
2231
An empty list means the markdown passed every check.
2332
2433
Examples:
2534
>>> from in2lambda.validation import check_markdown
2635
>>> check_markdown("Inline $x = y$ is fine.")
2736
[]
2837
>>> check_markdown("Unbalanced $x = y")
29-
[<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>]
38+
[MathDelimiterProblem(line=1, error=<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>)]
3039
"""
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
40+
return math_delimiter_checker(md_content)

‎in2lambda/validation/delimiters.py‎

Lines changed: 79 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66
delimiter mistake it finds.
77
"""
88

9+
from dataclasses import dataclass
910
from enum import Enum
1011

1112

1213
class MathDelimiterError(Enum):
13-
"""Outcome of :func:`math_delimiter_checker`.
14+
"""A specific delimiter mistake found by :func:`math_delimiter_checker`.
1415
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.
16+
The value is a short human-readable message suitable for showing on the
17+
command line.
1818
"""
1919

20-
PASSED = "ok"
2120
MISSING_NEWLINE_BEFORE_OPENING_DISPLAY = "opening $$ must start its own line"
2221
MISSING_NEWLINE_AFTER_OPENING_DISPLAY = "opening $$ must be followed by a newline"
2322
DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE = "inline $ ... $ closed with $$"
@@ -26,40 +25,58 @@ class MathDelimiterError(Enum):
2625
)
2726
MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY = "closing $$ must start its own line"
2827
MISSING_NEWLINE_AFTER_CLOSING_DISPLAY = "closing $$ must be followed by a newline"
29-
INVALID_NEWLINE_INSIDE_INLINE = "newline inside an inline $ ... $ expression"
3028
MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $"
3129
MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$"
3230

3331

34-
def math_delimiter_checker(md_content: str) -> MathDelimiterError:
35-
r"""Scan markdown for the first math-delimiter mistake.
32+
@dataclass(frozen=True)
33+
class MathDelimiterProblem:
34+
"""A single delimiter mistake and the (1-based) line it was found on."""
35+
36+
line: int
37+
error: MathDelimiterError
38+
39+
def __str__(self) -> str:
40+
return f"line {self.line}: {self.error.value}"
41+
42+
43+
def math_delimiter_checker(md_content: str) -> list[MathDelimiterProblem]:
44+
r"""Scan markdown for every math-delimiter mistake.
3645
3746
``\$`` is treated as a literal dollar sign, not a delimiter.
3847
3948
Args:
4049
md_content: The markdown text to check.
4150
4251
Returns:
43-
``MathDelimiterError.PASSED`` if the delimiters are well formed,
44-
otherwise the member describing the first problem found.
52+
A list of :class:`MathDelimiterProblem`, one per mistake found, in
53+
the order they occur. An empty list means the delimiters are well
54+
formed.
4555
4656
Examples:
4757
>>> from in2lambda.validation.delimiters import math_delimiter_checker
4858
>>> math_delimiter_checker("An inline $x = y$ expression.")
49-
<MathDelimiterError.PASSED: 'ok'>
59+
[]
5060
>>> math_delimiter_checker("Display:\n$$\nx = y\n$$")
51-
<MathDelimiterError.PASSED: 'ok'>
61+
[]
5262
>>> 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 $ ... $'>
63+
[]
5664
>>> math_delimiter_checker("Run `echo $PATH` now.")
57-
<MathDelimiterError.PASSED: 'ok'>
65+
[]
66+
>>> math_delimiter_checker("Broken $x = y")
67+
[MathDelimiterProblem(line=1, error=<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>)]
5868
"""
69+
problems: list[MathDelimiterProblem] = []
70+
71+
def report(error: MathDelimiterError) -> None:
72+
problems.append(MathDelimiterProblem(md_content.count("\n", 0, idx) + 1, error))
73+
5974
# False once we are inside a math expression and awaiting its closing delimiter.
6075
expect_open_delimiter = True
6176
# While inside an expression, whether it opened with a single "$" (inline) or "$$" (display).
6277
expect_single_dollar = True
78+
# Line on which the currently open (unclosed) expression started.
79+
open_line = 1
6380

6481
# Backtick code spans/fences are not markdown math and must not be scanned for
6582
# "$" delimiters, e.g. a shell variable like `echo $PATH` or a fenced snippet.
@@ -109,52 +126,71 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError:
109126
if character == "$" and prev_character != "\\":
110127
if expect_open_delimiter:
111128
expect_open_delimiter = False
129+
open_line = md_content.count("\n", 0, idx) + 1
112130

113131
if next_character == "$":
114132
next_next_character = (
115133
md_content[idx + 2] if idx + 2 < len(md_content) else None
116134
)
117135
# "$$" must sit alone on its own line.
118136
if prev_character != "\n" and prev_character is not None:
119-
return MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY
120-
if next_next_character != "\n":
121-
return MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY
122-
123-
expect_single_dollar = False
137+
report(
138+
MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY
139+
)
140+
expect_open_delimiter, expect_single_dollar = True, True
141+
elif next_next_character != "\n":
142+
report(MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY)
143+
expect_open_delimiter, expect_single_dollar = True, True
144+
else:
145+
expect_single_dollar = False
124146
idx += 1 # Skip the second "$"; the loop increments idx again.
125147
else:
126148
expect_single_dollar = True
127149
else:
128150
expect_open_delimiter = True
129151

130152
if expect_single_dollar and next_character == "$":
131-
return MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE
153+
report(MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE)
154+
expect_open_delimiter, expect_single_dollar = True, True
132155

133156
elif not expect_single_dollar:
134157
if next_character != "$":
135-
return (
158+
report(
136159
MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE
137160
)
138-
139-
next_next_character = (
140-
md_content[idx + 2] if idx + 2 < len(md_content) else None
141-
)
142-
if prev_character != "\n" and prev_character is not None:
143-
return MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY
144-
if next_next_character != "\n" and next_next_character is not None:
145-
return MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY
146-
147-
idx += 1 # Skip the second "$"; the loop increments idx again.
148-
149-
# A newline may not appear inside an inline "$ ... $" expression.
150-
elif character == "\n" and not expect_open_delimiter and expect_single_dollar:
151-
return MathDelimiterError.INVALID_NEWLINE_INSIDE_INLINE
161+
expect_open_delimiter, expect_single_dollar = True, True
162+
else:
163+
next_next_character = (
164+
md_content[idx + 2] if idx + 2 < len(md_content) else None
165+
)
166+
if prev_character != "\n" and prev_character is not None:
167+
report(
168+
MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY
169+
)
170+
expect_open_delimiter, expect_single_dollar = True, True
171+
elif (
172+
next_next_character != "\n"
173+
and next_next_character is not None
174+
):
175+
report(
176+
MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY
177+
)
178+
expect_open_delimiter, expect_single_dollar = True, True
179+
180+
idx += 1 # Skip the second "$"; the loop increments idx again.
152181

153182
idx += 1
154183

155-
if expect_open_delimiter:
156-
return MathDelimiterError.PASSED
157-
elif expect_single_dollar:
158-
return MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR
159-
else:
160-
return MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR
184+
if not expect_open_delimiter:
185+
problems.append(
186+
MathDelimiterProblem(
187+
open_line,
188+
(
189+
MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR
190+
if expect_single_dollar
191+
else MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR
192+
),
193+
)
194+
)
195+
196+
return problems

‎tests/test_validation.py‎

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88

99
from in2lambda.validation import (
1010
MathDelimiterError,
11+
MathDelimiterProblem,
1112
check_markdown,
1213
math_delimiter_checker,
1314
)
1415

1516
E = MathDelimiterError
17+
P = MathDelimiterProblem
1618

1719
VALID = [
1820
"This is an inline math expression: $x = y$.",
@@ -39,50 +41,117 @@
3941
"Price \\$50 for $x + y = z$ calculation.",
4042
"Expression: $cost = \\$100$.",
4143
"Display:\n$$\ncost = \\$100\n$$",
44+
# A "$ ... $" expression may now span a newline before closing.
45+
"This is an inline math expression:$x \n= y$.",
46+
"This is an inline math expression:$\nx = y$.",
47+
"This is an inline math expression:$x = y\n$.",
48+
# A "$" inside a code span/fence is not math and must be ignored.
49+
"Run `echo $PATH` now.\n",
50+
"```bash\necho $HOME\n```\n",
4251
]
4352

4453
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),
54+
(
55+
"This is an inline math expression: $x = y.",
56+
[P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)],
57+
),
58+
(
59+
"This is an inline math expression: x = y$.",
60+
[P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)],
61+
),
62+
(
63+
"Expression $x = y$$.",
64+
[
65+
P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
66+
P(1, E.MISSING_CLOSING_SINGLE_DOLLAR),
67+
],
68+
),
69+
(
70+
"Expression $x = y$ and $a = b$ and $c =",
71+
[P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)],
72+
),
73+
(
74+
"Expression $$$x = y$$$.",
75+
[
76+
P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
77+
P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
78+
P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
79+
],
80+
),
5381
(
5482
"This is a display math expression:\n$$\nx = y\n",
55-
E.MISSING_CLOSING_DOUBLE_DOLLAR,
83+
[P(2, E.MISSING_CLOSING_DOUBLE_DOLLAR)],
5684
),
5785
(
5886
"This is a display math expression:\nx = y\n$$",
59-
E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY,
87+
[P(3, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY)],
6088
),
6189
(
6290
"This is a display math expression:$$\nx = y\n$$.",
63-
E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY,
91+
[
92+
P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
93+
P(3, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY),
94+
],
6495
),
6596
(
6697
"This is a display math expression:\n$$\nx = y\n$$.",
67-
E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY,
98+
[P(4, E.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY)],
99+
),
100+
(
101+
"Expression:\n$$text\nx = y\n$$",
102+
[
103+
P(2, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY),
104+
P(4, E.MISSING_NEWLINE_AFTER_OPENING_DISPLAY),
105+
],
106+
),
107+
(
108+
"Expression:\n$$\nx = y\ntext$$",
109+
[P(4, E.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY)],
110+
),
111+
(
112+
"Expression $$x = y$.",
113+
[
114+
P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
115+
P(1, E.MISSING_CLOSING_SINGLE_DOLLAR),
116+
],
117+
),
118+
(
119+
"Expression $x = y$$",
120+
[
121+
P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
122+
P(1, E.MISSING_CLOSING_SINGLE_DOLLAR),
123+
],
124+
),
125+
(
126+
"Expression $$x = y$",
127+
[
128+
P(1, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
129+
P(1, E.MISSING_CLOSING_SINGLE_DOLLAR),
130+
],
131+
),
132+
# An unclosed "$" must report MISSING_CLOSING_SINGLE_DOLLAR, not a
133+
# newline-related error, however many lines it spans before EOF.
134+
("This $x is unclosed.\n", [P(1, E.MISSING_CLOSING_SINGLE_DOLLAR)]),
135+
# Two independent problems on different lines are both reported.
136+
(
137+
"Expression $x = y$$.\nAnother $a = b$$.\n",
138+
[
139+
P(1, E.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE),
140+
P(2, E.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY),
141+
],
68142
),
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),
74143
]
75144

76145

77146
@pytest.mark.parametrize("content", VALID)
78147
def test_valid_markdown_passes(content: str) -> None:
79-
assert math_delimiter_checker(content) is E.PASSED
148+
assert math_delimiter_checker(content) == []
80149
assert check_markdown(content) == []
81150

82151

83152
@pytest.mark.parametrize("content, expected", INVALID)
84153
def test_invalid_markdown_is_reported(
85-
content: str, expected: MathDelimiterError
154+
content: str, expected: list[MathDelimiterProblem]
86155
) -> None:
87-
assert math_delimiter_checker(content) is expected
88-
assert check_markdown(content) == [expected]
156+
assert math_delimiter_checker(content) == expected
157+
assert check_markdown(content) == expected

0 commit comments

Comments
 (0)