%% leanify.sty -- a LaTeX article checked by Leanify.
%% Copyright (C) 2026 Marcus Rockel.
%% Licensed under the Apache License, Version 2.0
%% (https://www.apache.org/licenses/LICENSE-2.0).
%% Home: https://github.com/Corrram/leanify
%%
%% Leanify reads the article source itself: its sentences, formulas and the
%% macros of its preamble. This package does not change what the article says,
%% and Leanify reads no markup from it. It
%%   * loads the packages for the notation the grammar reads (\coloneqq from
%%     mathtools, \mathbb and \le from amssymb);
%%   * runs Leanify while the article compiles, when it can: if the compiler
%%     may run programs (shell escape) and the article lies in a Lean project
%%     (a lakefile in its folder or up to three folders above), every compile
%%     runs one command, `leanify tex`, which does the quick check and, with
%%     `certify`, updates the pin and builds the certificate (the first time,
%%     it writes the Lean files of the article). The command writes its
%%     findings as records, \jobname.leanify.out, which this package reads
%%     line by line: they appear as warnings at their lines of the article,
%%     where editors list them. Without shell escape, as on arXiv, nothing
%%     runs;
%%   * with the option `status`, marks every labelled result in the margin with
%%     its verification status, read from \jobname.leanify.tex, which
%%     #leanify_certify writes next to the article. Without the option the
%%     typeset article is unchanged (use it for journal submission);
%%   * records the Lean module that certifies the article (\leanifymodule);
%%   * prints, where \leanifyreadings stands, the formal reading of every
%%     definition and statement, with the version of Leanify and the vocabulary:
%%     what Lean was asked to prove, for a reader of the PDF to compare.
%%
%% Usage, in the preamble:
%%   \usepackage[status]{leanify}               % marks from the last build
%%   \usepackage[certify, vocab=unitsquare]{leanify}  % build on every compile
%% Options: status, certify (implies status), check=false (no quick check),
%% vocab=FIELDS (the fields of the vocabulary the article uses, e.g.
%% vocab={numbers,analysis}; see docs/vocabulary.md), notions={graph, finite graph}
%% (nouns the article uses without defining them) and quantities={degree} (words
%% that name numbers): Leanify reads both from the source, nothing is printed for
%% them, and \leanifyreport lists them (docs/writing.md), lean=FILE (its
%% compiled Lean file, if it is not Compiled.lean next to the article or in
%% the folder that `leanify init` made),
%% exe=COMMAND (the checker to run, e.g. exe=leanify for one on the PATH: the
%% quick check then runs outside a Lean project as well).
%% Compile with shell escape (pdflatex -shell-escape, or the editor's setting)
%% and with `lake` on the PATH. Leanify's output is in \jobname.leanify.log.
%% Optionally, \leanifyreport prints a one-line summary where it appears, and
%% \leanifyreadings the readings (in an appendix, say).
\NeedsTeXFormat{LaTeX2e}[2022/06/01]
\ProvidesPackage{leanify}[2026/11/10 v0.7 Leanify checks, status marks and readings]
\RequirePackage{mathtools}
\RequirePackage{amssymb}

\ExplSyntaxOn
\bool_new:N \l__leanify_status_bool
\bool_new:N \l__leanify_check_bool
\bool_new:N \l__leanify_certify_bool
\str_new:N \l__leanify_vocab_str
% The nouns and the words for numbers that the article uses without defining them
% (notions={graph, finite graph}, quantities={degree}). Leanify reads them from the
% source of the article; nothing is printed for them where they are declared, and
% \leanifyreport lists them.
\tl_new:N \l__leanify_notions_tl
\tl_new:N \l__leanify_quantities_tl
\str_new:N \l__leanify_lean_str
\str_new:N \l__leanify_exeopt_str
\keys_define:nn { leanify }
  {
    status  .bool_set:N = \l__leanify_status_bool ,
    check   .bool_set:N = \l__leanify_check_bool ,
    check   .initial:n  = true ,
    certify .code:n     =
      {
        \bool_set_true:N \l__leanify_certify_bool
        \bool_set_true:N \l__leanify_status_bool
      } ,
    certify .value_forbidden:n = true ,
    vocab   .str_set:N  = \l__leanify_vocab_str ,
    notions    .tl_set:N = \l__leanify_notions_tl ,
    quantities .tl_set:N = \l__leanify_quantities_tl ,
    lean    .str_set:N  = \l__leanify_lean_str ,
    exe     .str_set:N  = \l__leanify_exeopt_str ,
  }
\ProcessKeyOptions [ leanify ]
% The vocabulary the article is read with, as printed by \leanifyreport.
\tl_new:N \l__leanify_vocab_tl
\tl_set:Ne \l__leanify_vocab_tl { \l__leanify_vocab_str }
\tl_replace_all:Nnn \l__leanify_vocab_tl { , } { , ~ }
\cs_new:Npn \leanify@vocab
  {
    \str_if_empty:NTF \l__leanify_vocab_str { core }
      { core, ~ \tl_use:N \l__leanify_vocab_tl }
  }
\ExplSyntaxOff

% The Lean module whose #leanify_certify checks this article.
\newcommand*\leanifymodule[1]{\gdef\leanify@module{#1}}
\newcommand*\leanify@module{}

% Entries of the status file.
\newcommand*\leanifystatus[2]{\expandafter\gdef\csname leanify@st@#1\endcsname{#2}}
\newcommand*\leanifysummary[6]{%
  \gdef\leanify@summary{#2 of #1 claims verified (#3 derived in the text from
    their citations)\leanify@context; #5 of #6 sentences compiled; #4
    \ifnum#4=1 definition\else definitions\fi\ well defined}}
\newcommand*\leanify@summary{no status file (build the Lean module first)}
% The verified claims that follow a sentence of their result or proof that is not read:
% their readings have the hypotheses of the sentences that are read. They are verified
% as read and are not among the verified claims of the summary.
\newcommand*\leanifycontext[1]{\gdef\leanify@context{, and #1 more \ifnum#1=1 is\else
    are\fi\ verified as read, after a sentence of \ifnum#1=1 its\else their\fi\ result
    or proof that is not read}}
\newcommand*\leanify@context{}
\newcommand*\leanifyversion[1]{\gdef\leanify@version{~#1}}
\newcommand*\leanify@version{}
% Of the claims derived in the text, those the standard tactic closes; of the
% statements with hypotheses, those whose hypotheses are shown to hold together.
\newcommand*\leanifysteps[2]{\gdef\leanify@steps{; the standard tactic closes #1 of the #2
    claims derived in the text}}
\newcommand*\leanify@steps{}
\newcommand*\leanifyhypotheses[2]{\gdef\leanify@hyps{; of the #2 sets of hypotheses under
  which claims are made, #1 are shown to hold together}}
\newcommand*\leanify@hyps{}
% The statements quoted from the literature (a result whose heading cites its source and
% that the article does not prove): the assumptions of the article, and the verified
% claims that rest on them. What the article uses without defining it.
\newcommand*\leanifyquoted[2]{\gdef\leanify@quoted{; #1 \ifnum#1=1 statement is\else
    statements are\fi\ quoted from the literature and assumed, and #2 of the verified
    claims rest on \ifnum#1=1 it\else them\fi}}
\newcommand*\leanify@quoted{}
\newcommand*\leanifynotdefined[1]{\gdef\leanify@notdefined{; not defined in this article
    or in the vocabulary (a kind of object among them is assumed to have an element): #1}}
\newcommand*\leanify@notdefined{}
\newcommand*\leanifyreport{\textsf{Leanify}\leanify@version\ (vocabulary: \leanify@vocab):
  \leanify@summary\leanify@steps\leanify@hyps\leanify@quoted\leanify@notdefined.}

% The readings: what Lean was asked to prove. \leanifyreadings prints them where it
% stands. A reading is the reading of a version of the grammar with a vocabulary,
% so both are named, with the conventions the grammar applies where the text
% states no domain.
\newcommand*\leanify@readings{}
\newcommand\leanifydefinition[3]{%
  \g@addto@macro\leanify@readings{\leanify@printdefinition{#1}{#2}{#3}}}
\newcommand\leanifyreading[4]{%
  \g@addto@macro\leanify@readings{\leanify@printreading{#1}{#2}{#3}{#4}}}
\def\leanify@cap#1#2\@nil{\MakeUppercase{#1}#2}
\newcommand\leanify@entry[2]{%
  \par\smallskip\noindent\hangindent=1.5em\hangafter=1 #1:\ \(#2\)\par}
\newcommand\leanify@printdefinition[3]{%
  \leanify@entry{Definition (line~#1)}{#2\coloneqq#3}}
\newcommand\leanify@printreading[4]{%
  \leanify@entry{\leanify@cap#2\@nil
    \if\relax\detokenize{#1}\relax\else~\ref{#1}\fi\ (line~#3)}{#4}}
\newcommand\leanifyreadings{%
  \par\begingroup\small\raggedright
  \noindent\textsf{Leanify}\leanify@version\ read the definitions and statements
  of this article as follows, with the vocabulary \leanify@vocab. In a limit, a
  sum, a set or a threshold whose variable has no stated domain, the letters
  \(n\), \(m\), \(k\), \(j\), \(i\), \(N\), \(M\), \(K\) stand for
  integers and every other letter for a real number; \(\mathbb{N}\) contains
  \(0\); \(e^{x}\) is the exponential function and \(\log\) the natural
  logarithm; a statement also asserts that every term in it is defined.
  \ifx\leanify@readings\empty
    \par\smallskip\noindent No readings (build the Lean module first).\par
  \else\leanify@readings\fi
  \endgroup}

% The margin marks.
\newcommand*\leanify@markof[1]{%
  \ifcsname leanify@mk@#1\endcsname\csname leanify@mk@#1\endcsname\fi}
\newcommand*\leanify@mk@text{$\checkmark$\,derived}
\newcommand*\leanify@mk@argued{$\checkmark$\,argued}
\newcommand*\leanify@mk@verified{$\checkmark$\,checked}
\newcommand*\leanify@mk@partial{$\circ$\,partly checked}
\newcommand*\leanify@mk@compiled{$\circ$\,compiled}
\newcommand*\leanify@mk@quoted{$\circ$\,quoted}
\newcommand*\leanify@mk@refuted{$\times$\,false}
\newcommand*\leanify@mark[1]{%
  \ifcsname leanify@st@#1\endcsname
    \edef\leanify@st{\csname leanify@st@#1\endcsname}%
    \ifx\leanify@st\leanify@none\else
      \ifmmode\else\leavevmode\marginpar{\raggedright\footnotesize\sffamily
        \leanify@markof{\leanify@st}}\fi
    \fi
  \fi}
\def\leanify@none{none}

% Running Leanify during the compile. The package runs one command,
%   leanify tex "\jobname.tex" [--vocab FIELDS] [--certify] [--status] ...
% which checks the article and, with `certify', builds its certificate. It writes
% \jobname.leanify.out, which is read here line by line: the kind of a record on
% one line, then each of its fields on a line of its own.
%   W  line, message        a finding at a line of the article
%   F  file, line, message  a finding in a file the article inputs
%   M  message              a warning without a line
%   S  text                 a line of the summary, for the terminal
% What the command prints is in \jobname.leanify.log.
\ExplSyntaxOn
\str_new:N \l__leanify_root_str
\str_new:N \l__leanify_job_str
\str_new:N \l__leanify_line_str
\str_new:N \l__leanify_first_str
\str_new:N \l__leanify_kind_str
\str_new:N \l__leanify_args_str
\seq_new:N \l__leanify_fields_seq
\int_new:N \l__leanify_need_int
\bool_new:N \l__leanify_report_bool
\bool_new:N \l__leanify_header_bool
\ior_new:N \g__leanify_ior
\str_set:Nx \l__leanify_job_str { \c_sys_jobname_str }
\str_remove_all:Nn \l__leanify_job_str { " }
\str_const:Nx \c__leanify_article_str { \l__leanify_job_str .tex }
\str_const:Nx \c__leanify_log_str { \l__leanify_job_str .leanify.log }
\str_const:Nx \c__leanify_out_str { \l__leanify_job_str .leanify.out }
\str_const:Nx \c__leanify_status_str { \l__leanify_job_str .leanify.tex }
\cs_generate_variant:Nn \msg_warning:nnn { nne }
\cs_new_protected:Npn \__leanify_summary:n #1 { \typeout { Leanify: ~ #1 } }
\cs_generate_variant:Nn \__leanify_summary:n { e }

% A finding at line #1 of the article. \PackageWarningNoLine keeps `on input line
% N' on one line of the log, where editors look for it (l3msg would wrap it).
\cs_new_protected:Npn \__leanify_finding:nn #1#2
  { \PackageWarningNoLine { leanify } { #2 ~ on ~ input ~ line ~ #1 } }
\cs_generate_variant:Nn \__leanify_finding:nn { ee }
\msg_new:nnn { leanify } { lean } { #1 }
\msg_new:nnn { leanify } { no-shell }
  {
    Leanify ~ did ~ not ~ run, ~ because ~ shell ~ escape ~ is ~ off. ~
    Compile ~ with ~ pdflatex ~ -shell-escape ~ (or ~ turn ~ shell ~ escape ~
    on ~ in ~ the ~ editor) ~ to ~ check ~ the ~ article ~ as ~ it ~ compiles, ~
    or ~ load ~ the ~ package ~ with ~ check=false ~ to ~ silence ~ this
  }
\msg_new:nnn { leanify } { no-report }
  {
    Leanify ~ gave ~ no ~ report ~ on ~ the ~ article. ~ Is ~ lake ~ (or ~ the ~
    checker ~ named ~ by ~ the ~ option ~ exe) ~ on ~ the ~ PATH? ~ Its ~ output ~
    (in ~ \c__leanify_log_str) ~ begins: ~ #1
  }

% The Lean project: the nearest folder, up to three above, with a lakefile.
\cs_new_protected:Npn \__leanify_find_root:
  {
    \str_clear:N \l__leanify_root_str
    \clist_map_inline:nn { . , .. , ../.. , ../../.. }
      {
        \file_if_exist:nTF { ##1 / lakefile.toml }
          { \str_set:Nn \l__leanify_root_str {##1} \clist_map_break: }
          {
            \file_if_exist:nT { ##1 / lakefile.lean }
              { \str_set:Nn \l__leanify_root_str {##1} \clist_map_break: }
          }
      }
  }

% The checker: the command of the option `exe', or the project's. Before it runs
% an executable, lake checks that every module of the project is built, mathlib's
% included, which takes seconds on every compile. So the project's executable is
% run directly when it is current: in a project that requires Leanify, the
% package's executable if it is newer than the project's lake-manifest.json (an
% update of Leanify changes the manifest, and lake then rebuilds it); in the
% Leanify repository, its own executable if it is newer than the checker's
% sources. Otherwise lake runs it.
\str_new:N \l__leanify_exe_str
\str_new:N \l__leanify_ext_str
\str_new:N \l__leanify_cand_str
\bool_new:N \l__leanify_current_bool
\cs_new_protected:Npn \__leanify_find_exe:
  {
    \str_if_empty:NTF \l__leanify_exeopt_str
      { \__leanify_find_project_exe: }
      { \str_set_eq:NN \l__leanify_exe_str \l__leanify_exeopt_str }
  }
\cs_new_protected:Npn \__leanify_find_project_exe:
  {
    \str_clear:N \l__leanify_exe_str
    \sys_if_platform_windows:TF
      { \str_set:Nn \l__leanify_ext_str { .exe } }
      { \str_clear:N \l__leanify_ext_str }
    % a project that requires Leanify
    \str_set:Nx \l__leanify_cand_str
      { \l__leanify_root_str /.lake/packages/leanify/.lake/build/bin/leanify \l__leanify_ext_str }
    \file_if_exist:nT { \l__leanify_cand_str }
      {
        \file_compare_timestamp:nNnF { \l__leanify_root_str /lake-manifest.json } > { \l__leanify_cand_str }
          { \str_set_eq:NN \l__leanify_exe_str \l__leanify_cand_str }
      }
    % the Leanify repository
    \str_if_empty:NT \l__leanify_exe_str
      {
        \str_set:Nx \l__leanify_cand_str
          { \l__leanify_root_str /.lake/build/bin/leanify \l__leanify_ext_str }
        \file_if_exist:nT { \l__leanify_cand_str }
          {
            \bool_set_true:N \l__leanify_current_bool
            \clist_map_inline:nn
              {
                LeanifyCli, Leanify/Codes, Leanify/Lex, Leanify/Segment, Leanify/Math,
                Leanify/Grammar, Leanify/Elab, Leanify/Doc, Leanify/Check, Leanify/Render,
                Leanify/Vocab, Leanify/Syntax, Leanify/Meta, Leanify/Report,
                Leanify/Library/Build, Leanify/Library/Complex, Leanify/Library/Distributions,
                Leanify/Library/Fields, Leanify/Library/Numbers, Leanify/Library/Torus,
                Leanify/Library/UnitSquare
              }
              {
                \file_if_exist:nT { \l__leanify_root_str / ##1 .lean }
                  {
                    \file_compare_timestamp:nNnT
                      { \l__leanify_root_str / ##1 .lean } > { \l__leanify_cand_str }
                      { \bool_set_false:N \l__leanify_current_bool \clist_map_break: }
                  }
              }
            \bool_if:NT \l__leanify_current_bool
              { \str_set_eq:NN \l__leanify_exe_str \l__leanify_cand_str }
          }
      }
    \sys_if_platform_windows:T
      {
        \str_if_empty:NF \l__leanify_exe_str
          { \exp_args:NNnV \str_replace_all:Nnn \l__leanify_exe_str { / } \c_backslash_str }
      }
  }

% `leanify tex ARTICLE ...': the executable directly, or through lake. What it
% prints goes to the log.
\cs_new_protected:Npn \__leanify_tex:
  {
    \str_set:Nx \l__leanify_args_str
      {
        tex ~ " \c__leanify_article_str "
        \str_if_empty:NF \l__leanify_vocab_str { ~ --vocab ~ \l__leanify_vocab_str }
        \bool_if:NF \l__leanify_check_bool { ~ --no-check }
        \bool_if:NT \l__leanify_certify_bool { ~ --certify }
        \bool_if:NT \l__leanify_status_bool { ~ --status }
        \str_if_empty:NF \l__leanify_lean_str { ~ --lean ~ " \l__leanify_lean_str " }
        \tl_if_empty:NF \leanify@module { ~ --module ~ \leanify@module }
      }
    \str_if_empty:NTF \l__leanify_exe_str
      {
        \sys_shell_now:e
          {
            lake ~ -d ~ " \l__leanify_root_str " ~ exe ~ leanify ~ \l__leanify_args_str
            \c_space_tl > ~ " \c__leanify_log_str " ~ 2>&1
          }
      }
      {
        \sys_shell_now:e
          {
            " \l__leanify_exe_str " ~ \l__leanify_args_str
            \c_space_tl > ~ " \c__leanify_log_str " ~ 2>&1
          }
      }
  }

% The records of \jobname.leanify.out.
\cs_new_protected:Npn \__leanify_read_out:
  {
    \bool_set_true:N \l__leanify_header_bool
    \int_zero:N \l__leanify_need_int
    \ior_open:NnT \g__leanify_ior { \c__leanify_out_str }
      {
        \ior_str_map_inline:Nn \g__leanify_ior
          {
            \str_set:Nn \l__leanify_line_str {##1}
            \regex_replace_all:nnN { \r } { } \l__leanify_line_str
            \bool_if:NTF \l__leanify_header_bool
              {
                % the first line names the version: the command ran
                \bool_set_false:N \l__leanify_header_bool
                \bool_set_true:N \l__leanify_report_bool
              }
              { \__leanify_record_line: }
          }
        \ior_close:N \g__leanify_ior
      }
  }
\cs_new_protected:Npn \__leanify_record_line:
  {
    \int_compare:nNnTF { \l__leanify_need_int } = { 0 }
      {
        \str_set_eq:NN \l__leanify_kind_str \l__leanify_line_str
        \seq_clear:N \l__leanify_fields_seq
        \int_set:Nn \l__leanify_need_int
          { \str_case:VnF \l__leanify_kind_str { {W} {2} {F} {3} {M} {1} {S} {1} } { 0 } }
      }
      {
        \seq_put_right:NV \l__leanify_fields_seq \l__leanify_line_str
        \int_decr:N \l__leanify_need_int
        \int_compare:nNnT { \l__leanify_need_int } = { 0 } { \__leanify_record: }
      }
  }
\cs_new_protected:Npn \__leanify_record:
  {
    \str_case:Vn \l__leanify_kind_str
      {
        {W}
          {
            \__leanify_finding:ee
              { \seq_item:Nn \l__leanify_fields_seq { 1 } }
              { \seq_item:Nn \l__leanify_fields_seq { 2 } }
          }
        {F}
          {
            \msg_warning:nne { leanify } { lean }
              {
                \seq_item:Nn \l__leanify_fields_seq { 3 } \c_space_tl
                ( \seq_item:Nn \l__leanify_fields_seq { 1 } , ~ line ~
                \seq_item:Nn \l__leanify_fields_seq { 2 } )
              }
          }
        {M} { \msg_warning:nne { leanify } { lean } { \seq_item:Nn \l__leanify_fields_seq { 1 } } }
        {S} { \__leanify_summary:e { \seq_item:Nn \l__leanify_fields_seq { 1 } } }
      }
  }

% The first line of the log, to say why there is no report.
\cs_new_protected:Npn \__leanify_log_start:
  {
    \str_clear:N \l__leanify_first_str
    \ior_open:NnT \g__leanify_ior { \c__leanify_log_str }
      {
        \ior_str_map_inline:Nn \g__leanify_ior
          { \str_set:Nn \l__leanify_first_str {##1} \ior_map_break: }
        \ior_close:N \g__leanify_ior
      }
  }

\cs_new_protected:Npn \__leanify_run:
  {
    \__leanify_find_root:
    % in a Lean project, or with a checker named by the option `exe'
    \bool_lazy_or:nnT
      { ! \str_if_empty_p:N \l__leanify_root_str } { ! \str_if_empty_p:N \l__leanify_exeopt_str }
      {
        \bool_lazy_or:nnT { \l__leanify_check_bool } { \l__leanify_certify_bool }
          {
            \sys_if_shell_unrestricted:TF
              {
                \__leanify_find_exe:
                \__leanify_summary:e
                  {
                    \bool_if:NTF \l__leanify_certify_bool { certifying } { checking }
                    \c_space_tl \c__leanify_article_str
                  }
                \__leanify_tex:
                % a command that did not run leaves no records, or those of an
                % earlier version of the article
                \bool_set_false:N \l__leanify_report_bool
                \file_if_exist:nT { \c__leanify_out_str }
                  {
                    \file_compare_timestamp:nNnF
                      { \c__leanify_article_str } > { \c__leanify_out_str }
                      { \__leanify_read_out: }
                  }
                \bool_if:NF \l__leanify_report_bool
                  {
                    \__leanify_log_start:
                    \msg_warning:nne { leanify } { no-report } { \l__leanify_first_str }
                  }
              }
              { \msg_warning:nn { leanify } { no-shell } }
          }
      }
  }

\AtBeginDocument
  {
    \__leanify_run:
    % the status file: its entries are stored, and typeset only by the option
    % `status', \leanifyreport and \leanifyreadings
    \file_if_exist:nTF { \c__leanify_status_str }
      { \file_input:n { \c__leanify_status_str } }
      {
        \bool_if:NT \l__leanify_status_bool
          {
            \PackageWarningNoLine { leanify }
              { No ~ status ~ file ~ \c__leanify_status_str }
          }
      }
  }
\ExplSyntaxOff

% wrap \label after every package has set it up (hyperref redefines it)
\ExplSyntaxOn
\AddToHook { begindocument/end }
  {
    \bool_if:NT \l__leanify_status_bool
      {
        \cs_set_eq:NN \leanify@label \label
        \renewcommand* \label [1] { \leanify@label {#1} \leanify@mark {#1} }
      }
  }
\ExplSyntaxOff
\endinput
