#!/bin/sh
# Runs the five checks of the audit that had no script behind them, and
# reports each one by name.
#
# check_style.sh file.php ... check the named files
# check_style.sh --test run the self-test
#
# WHY THIS EXISTS. The audit before every patch lists eleven checks and
# asks for a pass or fail on each. Six had scripts: php -l, needsdocs,
# longlines, check_deprecated, check_words and a fresh Createdb. The
# other five had none, and were folded into a general pass without
# anything being run for them. Reporting a check that was not made is
# worse than skipping it, so here they are:
#
# a new single-letter PHP variable other than a loop index
# a name carrying a type or unit suffix
# a comment written with two slashes
# a new single-letter JavaScript variable other than a loop index
# an if, else or loop body without braces
#
# Exits non-zero when it finds one, so cut_patch.sh can gate on it.
# Reports what is wrong with the named files.
check_files()
{
python3 - "$@" <<'PYEOF'
import os
import re
import sys
faults = 0
# a name ending this way says what kind of thing it is rather than what
# it is for, which the docblock says instead
SUFFIXES = ("_arr", "_str", "_int", "_obj", "_bool", "_list", "_secs",
"_ms", "_bytes", "_num", "_cnt")
# Some of these endings are also the last word of a term of the field
# Yioop works in. A posting list, a position list and a delta list are
# what information retrieval calls those things, and Yioop's own
# docblocks call them that, so the word list there is part of a name and
# not a note about what type the name holds. A name below is read as a
# term and passed over. Add to this rather than renaming, where a name
# reported here turns out to be what the field calls the thing.
TERMS = ("posting_list", "position_list", "delta_list",
"encoded_position_list", "doc_list", "word_list", "candidate_list",
"stop_word_list", "black_list", "white_list", "seed_list",
"suffix_list", "mail_list", "phrase_list", "triplets_list",
"meta_words_list", "question_list", "question_answer_list")
for path in sys.argv[1:]:
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError:
continue
is_php = path.endswith(".php")
is_script = path.endswith(".js")
lines = text.split("\n")
# a string or a comment is not code, so both are blanked before the
# code itself is read
def blanked(found):
"""Keeps the line breaks and the length of what it blanks, so
that a line number stays what it was."""
return "".join(one if one == "\n" else " "
for one in found.group(0))
bare = re.sub(r'/\*.*?\*/', blanked, text, flags=re.S)
# Both kinds of quote are blanked in one pass, so that whichever
# opens first closes first. Blanking one kind and then the other let
# a double quote inside a single-quoted string open a string that ran
# on until the next double quote somewhere else, and the code in
# between was read as though it were text.
bare = re.sub(r'"(?:[^"\\]|\\.)*"' + r"|'(?:[^'\\]|\\.)*'",
blanked, bare, flags=re.S)
bare_lines = bare.split("\n")
# Coding.pdf lets a loop counter be one letter, and a fourth loop
# inside three others needs a fourth letter. Rather than naming which
# letters those may be, every one-letter name the file counts a loop
# with is read off first, and those are the ones let through. A
# one-letter name that never counts a loop is still reported.
# i, j and k are allowed outright, as the process file says. Beyond
# those, a one-letter name a loop declares, or one that is stepped
# along with ++ or --, is counting something and is allowed too.
counters = set(["i", "j", "k"])
counters |= set(re.findall(r'for\s*\(\s*\$([A-Za-z])\b', bare))
counters |= set(re.findall(
r'for\s*\(\s*(?:var|let|const)\s+([A-Za-z])\b', bare))
# A counter is a counter whichever loop it belongs to. A one-letter
# name that is stepped along, with ++ or --, is counting something,
# and the rule allows that whether the loop is a for or a while.
counters |= set(re.findall(r'\$([A-Za-z])\s*(?:\+\+|--)', bare))
counters |= set(re.findall(r'(?:\+\+|--)\s*\$([A-Za-z])\b', bare))
depth = 0
catch_depth = 0
in_catch = False
for at, line in enumerate(bare_lines):
spot = f"{path}:{at + 1}"
depth += line.count("{") - line.count("}")
if in_catch and depth < catch_depth:
in_catch = False
if is_php:
# $e is allowed for the thing a catch takes, and inside
# the block that catch opens, since that is where it is
# read. It says the same to every PHP writer, and Chris
# settled it.
if re.search(r'\bcatch\s*\(', line):
in_catch = True
catch_depth = depth
for name in re.findall(r'\$([A-Za-z])\b', line):
if name == "e" and in_catch:
continue
if name in counters:
continue
print(f"{spot}: a variable named for one letter, {name}")
faults += 1
for name in re.findall(r'\$(\w+)', line):
if name in TERMS or any(name.endswith("_" + one)
for one in TERMS):
continue
for ending in SUFFIXES:
if name.endswith(ending):
print(f"{spot}: a name saying what kind of "
f"thing it is, {name}")
faults += 1
break
if is_script:
for name in re.findall(r'\b(?:var|let|const)\s+([A-Za-z])\b',
line):
if name not in counters:
print(f"{spot}: a variable named for one letter "
f"({name})")
faults += 1
# Coding.pdf forbids a comment running over more than one line
# written as a run of two-slash lines, and asks for /* */ there.
# A single such line is what its own examples use, including the
# //no break that marks a switch case falling through, so one on
# its own passes.
if re.match(r'\s*//', line):
run_on = at + 1 < len(lines) and \
re.match(r'\s*//', lines[at + 1])
after = at > 0 and re.match(r'\s*//', lines[at - 1])
if run_on and not after:
print(f"{spot}: a comment over more than one line "
f"written with two slashes")
faults += 1
# a body on the same line as its test, with no brace, is the
# shape this looks for: if (...) something;
found = re.search(r'\b(if|else\s+if|while|for|foreach)\s*\(',
line)
# A while that closes a do block ends in a semicolon and has no
# body of its own, so it is not one of these. It is told apart by
# the brace that closes the do sitting before it.
if found and re.match(r'\s*\}\s*while\s*\(', line):
found = None
if found:
after = line[found.end() - 1:]
depth = 0
rest = ""
for at_char, letter in enumerate(after):
if letter == "(":
depth += 1
elif letter == ")":
depth -= 1
if depth == 0:
rest = after[at_char + 1:].strip()
break
if rest and not rest.startswith("{") and not \
rest.startswith("//") and not rest.startswith("/*"):
print(f"{spot}: a body with no braces")
faults += 1
if re.match(r'\s*else\s*[^{\s]', line) and "elseif" not in line \
and "else if" not in line:
print(f"{spot}: an else with no braces")
faults += 1
if faults:
print("check: the lines above break a rule of the audit")
sys.exit(1)
print("check: nothing found in " + str(len(sys.argv) - 1) + " file(s)")
sys.exit(0)
PYEOF
}
# Writes a file holding each fault and one holding none, and reports any
# the check gets wrong.
run_self_test()
{
where=`mktemp -d`
missed=0
cat > "$where/bad.php" <<'PHPEOF'
<?php
class Bad
{
public function work($x)
{
$name_str = "a";
if ($x) return 1;
// a comment with two slashes
// running on to a second line
return 0;
}
}
PHPEOF
cat > "$where/good.php" <<'PHPEOF'
<?php
class Good
{
public function work($wanted)
{
/* a comment on one line is allowed */
$one = 1; // and so is one at the end of a line
$name = "a";
for ($i = 0; $i < 3; $i++) {
$name .= $i;
}
if ($wanted) {
return 1;
}
/* a comment written the way the rules ask */
return 0;
}
}
PHPEOF
if check_files "$where/bad.php" > /dev/null 2>&1; then
echo "self-test: a file breaking four rules was let through"
missed=`expr $missed + 1`
fi
if ! check_files "$where/good.php" > /dev/null 2>&1; then
echo "self-test: a file breaking nothing was refused"
check_files "$where/good.php"
missed=`expr $missed + 1`
fi
rm -rf "$where"
if [ "$missed" -eq 0 ]; then
echo "self-test: two files tried, none went wrong"
return 0
fi
echo "self-test: $missed of two files went wrong"
return 1
}
if [ "$1" = "--test" ]; then
run_self_test
exit $?
fi
if [ -z "${1:-}" ]; then
echo "check: name the files to check"
exit 2
fi
check_files "$@"