Skip to content
The Lumenary

Math laboratory

Linting LaTeX: environments, references, macros and style flags

Checks a draft and a revised LaTeX fragment for unbalanced environments, references to missing labels, undefined macros, sentences that start with a formula and words like 'clearly'. The analysis of the given text is exact, but these are mechanical style checks: they cannot detect mathematical errors.

Back to the labExact computation
Linting LaTeX: environments, references, macros and style flagsPythonExact computationComputationally tested
r"""A small LaTeX linter: the mechanical part of careful writing.

Some writing faults are mechanical enough for a program to find. This linter
reads a LaTeX fragment and reports:

* environments that are opened and never closed (or closed out of order);
* \ref and \eqref to labels that do not exist;
* macros that are used but neither standard nor defined by \newcommand;
* sentences that begin with a formula (hard to read: "$n$ is prime" after
  a full stop looks like a continuation);
* words that assert ease instead of giving a reason ("clearly", "obviously").

We lint a draft paragraph and its revision. The checks are exact for the text
given, but they are heuristics about style: they find no mathematical errors,
and a clean report does not mean the writing is good.
"""

import re

#: Commands a document with amsmath and amsthm may use without defining them.
STANDARD = {
    "begin", "end", "label", "ref", "eqref", "cite", "emph", "textbf", "frac",
    "mathbb", "mathrm", "sum", "prod", "le", "ge", "ne", "in", "cdot", "dots",
    "ldots", "mid", "nmid", "equiv", "pmod", "bmod", "to", "mapsto", "infty",
    "alpha", "beta", "gamma", "delta", "varepsilon", "lambda", "pi", "sqrt",
    "left", "right", "quad", "qquad", "text", "newcommand", "section",
    "subsection", "item", "forall", "exists", "neg", "wedge", "vee", "sim",
    "binom", "lfloor", "rfloor", "colon", "subseteq", "cup", "cap", "setminus",
    "gcd",
}
STYLE_WORDS = ("clearly", "obviously", "trivially", "it is easy to see")

DRAFT = r"""
\begin{theorem}\label{thm:main}
$n^2-1$ is divisible by $24$ for $p>3$ prime.
\end{theorem}
\begin{proof}
Clearly $p^2 - 1 = (p-1)(p+1)$. $p$ is odd so both factors are even, and
one is divisible by $4$. One of $p-1,p,p+1$ is a multiple of $3$, so we are
done by Lemma~\ref{lem:three} and \Ztwo-arguments.
\end{theorem}
"""

REVISION = r"""
\newcommand{\Z}{\mathbb{Z}}
\begin{lemma}\label{lem:three}
Among any three consecutive integers, exactly one is divisible by $3$.
\end{lemma}
\begin{theorem}\label{thm:main}
If $p > 3$ is prime, then $24 \mid p^2 - 1$.
\end{theorem}
\begin{proof}
Write $p^2 - 1 = (p - 1)(p + 1)$. Since $p$ is odd, $p - 1$ and $p + 1$
are consecutive even numbers, so one of them is divisible by $4$ and
$8 \mid p^2 - 1$. By Lemma~\ref{lem:three}, one of $p - 1, p, p + 1$ is
divisible by $3$; it is not $p$, since $p > 3$ is prime. Hence
$3 \mid p^2 - 1$, and as $\gcd(3, 8) = 1$, we get $24 \mid p^2 - 1$
in $\Z$.
\end{proof}
"""

def environment_problems(text):
    """Unclosed or mismatched \\begin/\\end pairs."""
    stack, problems = [], []
    for kind, name in re.findall(r"\\(begin|end)\{(\w+)\}", text):
        if kind == "begin":
            stack.append(name)
        elif not stack or stack[-1] != name:
            opened = stack[-1] if stack else "nothing"
            problems.append(f"\\end{{{name}}} closes {opened}")
            if stack:
                stack.pop()
        else:
            stack.pop()
    problems += [f"\\begin{{{name}}} never closed" for name in stack]
    return problems

def missing_labels(text):
    """References whose label is not defined in the text."""
    labels = set(re.findall(r"\\label\{([^}]*)\}", text))
    refs = re.findall(r"\\(?:eq)?ref\{([^}]*)\}", text)
    return sorted({r for r in refs if r not in labels})

def undefined_macros(text):
    """Macros used but neither standard nor defined with \\newcommand."""
    defined = set(re.findall(r"\\newcommand\{\\(\w+)\}", text))
    used = set(re.findall(r"\\([A-Za-z]+)", text))
    return sorted(used - STANDARD - defined)

def sentences_starting_with_math(text):
    """Sentences beginning with $ (after . ! ? or at the start of an
    environment); line breaks inside a paragraph are ignored."""
    body = re.sub(r"\\(begin|end)\{\w+\}(\\label\{[^}]*\})?", ". ", text)
    body = " ".join(body.split())
    return len(re.findall(r"[.!?]\s+\$", body))

def style_flags(text):
    lower = text.lower()
    return [w for w in STYLE_WORDS if w in lower]

def report(name, text):
    lines = [f"{name}:"]
    checks = [
        ("environments", environment_problems(text)),
        ("missing labels", missing_labels(text)),
        ("undefined macros", ["\\" + m for m in undefined_macros(text)]),
        ("style words", style_flags(text)),
    ]
    for title, found in checks:
        lines.append(f"  {title:<17} {', '.join(found) if found else 'none'}")
    lines.append(f"  {'formula-initial':<17} {sentences_starting_with_math(text)}"
                 " sentence(s)")
    return lines

def main():
    for line in report("Draft", DRAFT) + [""] + report("Revision", REVISION):
        print(line)
    print()
    print("Not detected by any linter: the draft's theorem says n where it")
    print("means p, and its proof never explains why the multiple of 3 is not")
    print("p itself. Only a careful reader (or a proof assistant) catches that.")
    print()
    print("Epistemic status: exact pattern matching on the two fragments shown.")
    print("The checks are heuristics about mechanics and style; a clean report")
    print("says nothing about whether the mathematics is correct or clear.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in