/ devlog / devscripts / check_orphans.sh
#!/bin/sh
# Names the methods and functions that nothing in the source can reach.
#
#   sh devlog/devscripts/check_orphans.sh            every php file under src
#   sh devlog/devscripts/check_orphans.sh <file>...  only the files named
#
# WHY THIS EXISTS. Splitting a long method means lifting a piece of it
# into a method of its own and calling that method where the piece was.
# Where the call is forgotten, nothing complains: the file parses, the
# tests pass, and the screen still draws, because what was dropped is
# work the page did rather than something it shows. That is how
# editWikiHeadVars came to hold every wiki page setting a writer picks
# while nothing called it, so every setting was dropped on save.
#
# A name is treated as reachable where it appears anywhere in the source
# other than on the line that declares it. That is deliberately careful:
# a name is reached in ways a reader of one line cannot see, such as
# IndexShard::makeWords, which is written inside a longer string and
# called through it. Counting only calls took that method away and
# stopped the test run. Names PHP itself calls, those ending in
# Modifiers, and the database upgrade functions are passed over for the
# same reason. What is left is reached by nothing, and either wants a
# call or wants deleting.
#
# It exits non-zero when it names anything, so it can gate a cut.
reader=/tmp/check_orphans.$$.php
cat > "$reader" << 'PHPEOF'
<?php
/* Reads every php file under src, and names each method that nothing in
   the source can reach. */
$named = array_slice($argv, 1);
$files = [];
/* The reading starts at the root of the repository, not at src. Yioop's
   own web server is the file index.php sitting there, and what it calls
   from its timers is called nowhere else: IndexManager::cacheReport was
   taken away as unreachable because this read src alone, and the server
   died on it minutes after it started. */
$walk = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("."));
foreach ($walk as $file) {
    if ($file->isDir() || substr($file->getFilename(), -4) !== ".php") {
        continue;
    }
    $path = preg_replace('@^\./@', "", $file->getPathname());
    if (str_starts_with($path, "work_directory/") ||
        str_starts_with($path, "devlog/")) {
        continue;
    }
    $files[$path] = file_get_contents($path);
}
/* PHP does not care about the case of a method name, so getCurrentmachine
   reaches getCurrentMachine. Every match below is made without regard to
   case; reading it otherwise took that method away and stopped the media
   updater.

   A name that appears only inside a comment is not reached by anything:
   prose about a method is not a call to it. Comments are blanked before
   the source is read for names, so a method whose only other mention is
   in a docblock counts as unreachable. */
foreach ($files as $where => $held) {
    $bare = "";
    foreach (token_get_all($held) as $token) {
        if (is_array($token) && ($token[0] == T_COMMENT ||
            $token[0] == T_DOC_COMMENT)) {
            $bare .= str_repeat("\n", substr_count($token[1], "\n"));
        } else {
            $bare .= is_array($token) ? $token[1] : $token;
        }
    }
    $files[$where] = $bare;
}
$whole = implode("\n", $files);
/* Every front and back joined into a name that is then called through a
   variable. A method whose name begins with such a front, or ends with
   such a back, may be reached by that call however few written calls it
   has. */
$fronts = [];
$backs = [];
foreach ($files as $source) {
    if (!preg_match_all('/(?:->|::)\$(\w+)\s*\(|method_exists\s*\(' .
        '[^,]+,\s*\$(\w+)\s*\)/', $source, $found, PREG_SET_ORDER)) {
        continue;
    }
    $held = [];
    foreach ($found as $one) {
        foreach (array_slice($one, 1) as $which) {
            if ($which !== "") {
                $held[$which] = true;
            }
        }
    }
    foreach (array_keys($held) as $which) {
        if (!preg_match_all('/\$' . preg_quote($which, "/") .
            '\s*=\s*([^;]+);/', $source, $where)) {
            continue;
        }
        foreach ($where[1] as $said) {
            if (preg_match_all('/"([A-Za-z_]\w*)\{?\$/', $said, $bit)) {
                $fronts = array_merge($fronts, $bit[1]);
            }
            if (preg_match_all('/[\'"]([A-Za-z_]\w*)[\'"]\s*\./', $said,
                $bit)) {
                $fronts = array_merge($fronts, $bit[1]);
            }
            if (preg_match_all('/\}([A-Za-z_]\w*)"/', $said, $bit)) {
                $backs = array_merge($backs, $bit[1]);
            }
            if (preg_match_all('/\.\s*[\'"]([A-Za-z_]\w*)[\'"]/', $said,
                $bit)) {
                $backs = array_merge($backs, $bit[1]);
            }
        }
    }
}
$fronts = array_unique($fronts);
$backs = array_unique($backs);
$faults = 0;
foreach ($files as $path => $source) {
    /* The atto servers are read for names that reach into Yioop, since
       MailSite declares userExists and getPasswordHash and the class
       under src/library/mail answers for them, but they are not read for
       methods of their own: their master copy is another project's. */
    /* Everything is read for the names it calls; only src and the root
       index.php are read for methods of their own. A test is not a
       caller worth keeping a method for, and the atto servers are
       another project's. */
    if (!str_starts_with($path, "src/") && $path !== "index.php") {
        continue;
    }
    if (str_contains($path, "/library/atto_servers/")) {
        continue;
    }
    /* What handles advertisements and credits is reached from a script
       people buy to run beside Yioop, which is not in this source, so a
       method there is left alone however little of Yioop calls it. */
    if (str_contains($path, "Advertisement") || str_contains($path,
        "Credit")) {
        continue;
    }
    if ($named && !in_array($path, $named)) {
        continue;
    }
    $tokens = token_get_all($source);
    $count = count($tokens);
    $class = "";
    for ($i = 0; $i < $count; $i++) {
        $one = $tokens[$i];
        if (is_array($one) && in_array($one[0],
            [T_CLASS, T_TRAIT, T_INTERFACE])) {
            for ($j = $i + 1; $j < $count; $j++) {
                if (is_array($tokens[$j]) && $tokens[$j][0] == T_STRING) {
                    $class = $tokens[$j][1];
                    break;
                }
            }
        }
        if (!is_array($one) || $one[0] != T_FUNCTION) {
            continue;
        }
        $name = "";
        for ($j = $i + 1; $j < $count; $j++) {
            if (is_array($tokens[$j]) && $tokens[$j][0] == T_STRING) {
                $name = $tokens[$j][1];
                break;
            }
            if ($tokens[$j] === "(") {
                break;
            }
        }
        if ($name === "" || str_starts_with($name, "__") ||
            str_starts_with($name, "upgradeDatabaseVersion")) {
            continue;
        }
        /* A name built at run time reaches a method no call names. The
           fronts and backs joined into such a name are read off the
           source above, so ArcTool's "outputInfo" . $archive_type and
           ClassifierTool's "run{$activity}" both count. */
        $built = false;
        foreach ($fronts as $front) {
            if (strcasecmp($name, $front) != 0 &&
                stripos($name, $front) === 0) {
                $built = true;
                break;
            }
        }
        foreach ($backs as $back) {
            if (strcasecmp($name, $back) != 0 && strlen($name) >=
                strlen($back) && strcasecmp(substr($name,
                -strlen($back)), $back) == 0) {
                $built = true;
                break;
            }
        }
        if ($built) {
            continue;
        }
        $quoted = preg_quote($name, "/");
        /* Any mention of the name anywhere in the source counts, not
           only a call. A name is reached in ways a reader of one line
           cannot see: written inside a longer string and called through
           it, as IndexShard::makeWords is; named in a table of rules or
           routes; or answering for a name a parent declares. Counting
           only calls took makeWords away and stopped the tests. So the
           rule here is the careful one: a method is named as
           unreachable only where its name appears nowhere else at
           all. */
        if (preg_match_all('/(?<![\w$])' . $quoted . '(?![\w])/i',
            $whole) > 1) {
            continue;
        }
        printf("%s:%d: nothing can reach %s\n", $path, $one[2],
            ($class === "") ? $name : "$class::$name");
        $faults++;
    }
}
if ($faults == 0) {
    echo "check: every method can be reached\n";
    exit(0);
}
echo "check: the methods above are reached by nothing; give each a call " .
    "or take it away\n";
exit(1);
PHPEOF
php "$reader" "$@"
result=$?
rm -f "$reader"
exit $result
X