|
| 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 |
0 commit comments