lean/scripts/lint_style.py (234 lines of code) (raw):
#!/usr/bin/env python3
"""
Lint a file or files from mathlib for style.
Sample usage:
under the folder of miniF2F/lean
$ python ./scripts/lint_style.py $(find src/test -name '*.lean')
$ python ./scripts/lint_style.py $(find src/valid -name '*.lean')
which will lint all of the Lean files in the specified directories.
The resulting error output will contain one line for each style error
encountered that isn't in the list of allowed / ignored style exceptions.
Paths with no errors will not appear in the output, and the script will
exit with successful return code if there are no errors encountered in
any provided paths.
Paths emitted in the output will match the paths provided on the
command line for any files containing errors -- in particular, linting
a relative path (like ``src/foo/bar.lean``) will produce errors
that contain the relative path, whilst linting absolute paths (like
``/root/mathlib/src/foo/bar.lean``) will produce errors with the
absolute path.
This script can also be used to regenerate the list of allowed / ignored style
exceptions by redirecting the output to ``style-exceptions.txt``:
$ python ./scripts/lint_style.py $(find src -name '*.lean') > scripts/style-exceptions.txt
"""
from pathlib import Path
import sys
ERR_COP = 0 # copyright header
ERR_IMP = 1 # import statements
ERR_MOD = 2 # module docstring
ERR_LIN = 3 # line length
ERR_SAV = 4 # ᾰ
ERR_RNT = 5 # reserved notation
ERR_OPT = 6 # set_option
ERR_AUT = 7 # malformed authors list
ERR_TSP = 8 # tailing space
ERR_SOR = 9 # sorry with comma
ERR_PSP = 10 # parenthesis with space
ERR_EOF = 11 # new line in end of file
exceptions = []
SCRIPTS_DIR = Path(__file__).parent.resolve()
ROOT_DIR = SCRIPTS_DIR.parent
RESERVED_NOTATION = ROOT_DIR / 'src/tactic/reserved_notation.lean'
new_exceptions = False
def skip_comments(enumerate_lines):
in_comment = False
for line_nr, line in enumerate_lines:
if "/-" in line:
in_comment = True
if "-/" in line:
in_comment = False
continue
if line == "\n" or in_comment:
continue
yield line_nr, line
def skip_string(enumerate_lines):
in_string = False
in_comment = False
for line_nr, line in enumerate_lines:
# ignore comment markers inside string literals
if not in_string:
if "/-" in line:
in_comment = True
if "-/" in line:
in_comment = False
# ignore quotes inside comments
if not in_comment:
# crude heuristic: if the number of non-escaped quote signs is odd,
# we're starting / ending a string literal
if line.count("\"") - line.count("\\\"") % 2 == 1:
in_string = not in_string
# if there are quote signs in this line,
# a string literal probably begins and / or ends here,
# so we skip this line
if line.count("\"") > 0:
continue
if in_string:
continue
yield line_nr, line
def small_alpha_vrachy_check(lines, path):
errors = []
for line_nr, line in skip_string(skip_comments(enumerate(lines, 1))):
if 'ᾰ' in line:
errors += [(ERR_SAV, line_nr, path)]
return errors
def reserved_notation_check(lines, path):
if path.resolve() == RESERVED_NOTATION:
return []
errors = []
for line_nr, line in skip_string(skip_comments(enumerate(lines, 1))):
if line.startswith('reserve') or line.startswith('precedence'):
errors += [(ERR_RNT, line_nr, path)]
return errors
def set_option_check(lines, path):
errors = []
for line_nr, line in skip_string(skip_comments(enumerate(lines, 1))):
if line.startswith('set_option'):
next_two_chars = line.split(' ')[1][:2]
# forbidden options: pp, profiler, trace
if next_two_chars == 'pp' or next_two_chars == 'pr' or next_two_chars == 'tr':
errors += [(ERR_OPT, line_nr, path)]
return errors
def long_lines_check(lines, path):
errors = []
# TODO: some string literals (in e.g. tactic output messages) can be excepted from this rule
for line_nr, line in enumerate(lines, 1):
if "http" in line:
continue
if len(line) > 101:
errors += [(ERR_LIN, line_nr, path)]
return errors
def tailing_space_check(lines, path):
errors = []
for line_nr, line in enumerate(lines, 1):
if line.endswith(' \n'):
errors += [(ERR_TSP, line_nr, path)]
return errors
def sorry_comma_check(lines, path):
errors = []
for line_nr, line in enumerate(lines, 1):
if "sorry," in line:
errors += [(ERR_SOR, line_nr, path)]
return errors
def parathesis_space_check(lines, path):
errors = []
for line_nr, line in enumerate(lines, 1):
if "( " in line or " )" in line:
errors += [(ERR_PSP, line_nr, path)]
return errors
def eof_check(lines, path):
if not lines[-1].endswith("\n"):
return [(ERR_EOF, len(lines), path)]
return []
def import_only_check(lines, path):
import_only_file = True
errors = []
for line_nr, line in skip_comments(enumerate(lines, 1)):
imports = line.split()
if imports[0] == "--":
continue
if imports[0] != "import":
import_only_file = False
break
if len(imports) > 2:
if imports[2] == "--":
continue
else:
errors += [(ERR_IMP, line_nr, path)]
return (import_only_file, errors)
def regular_check(lines, path):
errors = []
copy_started = False
copy_done = False
copy_start_line_nr = 0
copy_lines = ""
for line_nr, line in enumerate(lines, 1):
if not copy_started and line == "\n":
errors += [(ERR_COP, copy_start_line_nr, path)]
continue
if not copy_started and line == "/-\n":
copy_started = True
copy_start_line_nr = line_nr
continue
if not copy_started:
errors += [(ERR_COP, line_nr, path)]
if copy_started and not copy_done:
copy_lines += line
if "Author" in line:
# Validating names is not a reasonable thing to do,
# so we just look for the two common variations:
# using ' and ' between names, and a '.' at the end of line.
if ((not line.startswith("Authors: ")) or
(" " in line) or
(" and " in line) or
(line[-2] == '.')):
errors += [(ERR_AUT, line_nr, path)]
if line == "-/\n":
if ((not "Copyright" in copy_lines) or
(not "Apache" in copy_lines) or
(not "Authors: " in copy_lines)):
errors += [(ERR_COP, copy_start_line_nr, path)]
copy_done = True
continue
if copy_done and line == "\n":
continue
words = line.split()
if words[0] != "import" and words[0] != "/-!":
# errors += [(ERR_MOD, line_nr, path)]
break
if words[0] == "/-!":
break
# final case: words[0] == "import"
if len(words) > 2:
if words[2] != "--":
errors += [(ERR_IMP, line_nr, path)]
return errors
def output_message(path, line_nr, code, msg):
if len(exceptions) == 0:
# we are generating a new exceptions file
# filename first, then line so that we can call "sort" on the output
print(f"{path} : line {line_nr} : {code} : {msg}")
else:
# We are outputting for github. It doesn't appear to surface code, so show it in the message too
print(f"::error file={path},line={line_nr},code={code}::{code}: {msg}")
def format_errors(errors):
global new_exceptions
for errno, line_nr, path in errors:
if (errno, path.resolve()) in exceptions:
continue
new_exceptions = True
if errno == ERR_COP:
output_message(path, line_nr, "ERR_COP",
"Malformed or missing copyright header")
if errno == ERR_IMP:
output_message(path, line_nr, "ERR_IMP",
"More than one file imported per line")
if errno == ERR_MOD:
output_message(path, line_nr, "ERR_MOD",
"Module docstring missing, or too late")
if errno == ERR_LIN:
output_message(path, line_nr, "ERR_LIN",
"Line has more than 100 characters")
if errno == ERR_SAV:
output_message(path, line_nr, "ERR_SAV",
"File contains the character ᾰ")
if errno == ERR_RNT:
output_message(path, line_nr, "ERR_RNT",
"Reserved notation outside tactic.reserved_notation")
if errno == ERR_OPT:
output_message(path, line_nr, "ERR_OPT",
"Forbidden set_option command")
if errno == ERR_AUT:
output_message(path, line_nr, "ERR_AUT",
"Authors line should look like: 'Authors: Jean Dupont, Иван Иванович Иванов'")
if errno == ERR_TSP:
output_message(path, line_nr, "ERR_TSP",
"Tailing space detected")
if errno == ERR_SOR:
output_message(path, line_nr, "ERR_SOR",
"Sorry should not be followed by comma")
if errno == ERR_PSP:
output_message(path, line_nr, "ERR_PSP",
"'( ' or ' )' detected")
if errno == ERR_EOF:
output_message(path, line_nr, "ERR_EOF",
"missing \\n at the end of file")
def lint(path):
with path.open(encoding="utf-8") as f:
lines = f.readlines()
errs = eof_check(lines, path)
format_errors(errs)
errs = tailing_space_check(lines, path)
format_errors(errs)
errs = sorry_comma_check(lines, path)
format_errors(errs)
errs = parathesis_space_check(lines, path)
format_errors(errs)
(b, errs) = import_only_check(lines, path)
if b:
format_errors(errs)
return
errs = regular_check(lines, path)
format_errors(errs)
errs = small_alpha_vrachy_check(lines, path)
format_errors(errs)
errs = reserved_notation_check(lines, path)
format_errors(errs)
errs = set_option_check(lines, path)
format_errors(errs)
for filename in sys.argv[1:]:
lint(Path(filename))
# if "exceptions" is empty,
# we're trying to generate style-exceptions.txt,
# so new exceptions are expected
if new_exceptions and len(exceptions) > 0:
exit(1)