#!/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