66delimiter mistake it finds.
77"""
88
9+ from dataclasses import dataclass
910from enum import Enum
1011
1112
1213class 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
0 commit comments