/ devlog / devscripts / check_style.sh
#!/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 "$@"
X