/ src / executables / CodeTool.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * Tool used to help coding with Yioop. Has commands to update copyright info,
 * clean trailing spaces, find long lines, and do global file searches and
 * replaces.
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\executables;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\library\DocblockChecker;
use seekquarry\yioop\library\Utility;
use seekquarry\yioop\controllers\TestsController;

/*
 * Number of characters of file text to show on each side of a search
 * match, so a search result reads as a short snippet with the match in
 * the middle rather than as a bare matched string.
 */
const SEARCH_CONTEXT_CHARS = 30;

if (php_sapi_name() != 'cli' ||
    defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER")) {
    echo "BAD REQUEST"; exit();
}
/** Load in global configuration settings */
require_once __DIR__ . '/../configs/Config.php';
if (!C\PROFILE) {
    echo "Please configure the search engine instance by visiting " .
        "its web interface on localhost.\n";
    exit();
}
/*
 * We'll set up multi-byte string handling to use UTF-8
 */
mb_internal_encoding("UTF-8");
mb_regex_encoding("UTF-8");
$no_instructions = false;
$model = new Model();
$db = $model->db;
$commands = ["comments", "copyright", "clean", "longlines", "needsdocs",
    "probe", "search", "replace", "unit"];
$change_extensions = ["php", "js", "ini", "css", "thtml", "xml", "h", "cpp",
    "java", "py"];
$exclude_paths_containing = ["/.", "/extensions/"];
$num_spaces_tab = 4;
if (isset($argv[1]) && in_array($argv[1], $commands)) {
    if ($argv[1] == 'comments') {
        $argv[1] = 'needsdocs';
    }
    $command = C\NS_EXEC . $argv[1];
    array_shift($argv);
    array_shift($argv);
    $no_instructions = $command($argv);
}
if (!$no_instructions) {
    echo <<< EOD
CodeTool.php has the following command formats:

php CodeTool.php clean path
    Replaces all tabs with four spaces and trims all whitespace off ends of
    lines in the folder or file path. Removes trailing ?> from files
    Adds a space between if, for, foreach, etc and ( if not present

php CodeTool.php comments path
php CodeTool.php needsdocs path
    Scans the folder or file for missing, empty, or incomplete docblocks
    and prints one issue per line. Walks PHP via the lexer and JS via
    regex. Reports missing/empty docblocks on files, classes, interfaces,
    traits, enums, functions, methods, properties, and class constants;
    @param tags missing or stale relative to the parameter list; @return
    tags missing on functions whose body returns a value; and @var tags
    missing on property docblocks. Docblocks with @ignore or @inheritDoc
    are exempt from @param/@return enforcement. Closures, arrow
    functions, and the ::class magic constant are skipped. A per-type
    tally prints at the end.

php CodeTool.php copyright path
    Adjusts all lines in the files in the folder at path (or if
    path is a file just that) of the form 2009 - \d\d\d\d to
    the form 2009 - this_year where this_year is the current year.

php CodeTool.php longlines path
    Prints out all lines in files in the folder or file path which are
    longer than 80 characters.

php CodeTool.php replace path pattern replace_string
  or
php CodeTool.php replace path pattern replace_string effect
    Prints all lines matching the regular expression pattern followed
    by the result of replacing pattern with replace_string in the
    folder or file path. Does not change files.

php CodeTool.php replace path pattern replace_string interactive
    Prints each line matching the regular expression pattern followed
    by the result of replacing pattern with replace_string in the
    folder or file path. Then it asks if you want to update the line.
    Lines you choose for updating will be modified in the files.

php CodeTool.php replace path pattern replace_string change
    Each line matching the regular expression pattern is update
    by replacing pattern with replace_string in the
    folder or file path. This format doe not echo anything, it does a global
    replace without interaction.

php CodeTool.php search path pattern
    Prints all lines matching the regular expression pattern in the
    folder or file path.

php CodeTool.php unit list
    List available unit tests for Yioop

php CodeTool.php unit [test_class] [test_method]
    If test_class and test_method not supplied then runs all unit tests.
    If just test_class supplied then runs all the unit tests in test_class
    If both test_class and test_method supplied runs the unit tests given
    in test_method of test_class.

php CodeTool.php unit -f filter
php CodeTool.php unit --filter filter
    Runs all unit tests whose class name contains filter
    (case-insensitive). For example, unit -f Mail runs every test
    class with Mail in its name.

php CodeTool.php probe controller activity screen_file
    Calls one activity of one controller once for each screen named in
    screen_file and prints everything the activity hands its view. Each
    line of screen_file is a name for the screen, a space, and the query
    a browser would send, such as:
        history arg=history&group_id=1&page_name=Main
    A line starting with # is a note and is skipped. Values that differ
    from run to run, such as the reader's mark and the time, are written
    as TOKEN and TIME so that two runs can be compared. Taking this
    before a long method is split and again after, and comparing the
    two, says whether the split changed what any screen shows. Add
    public before the query to ask as a reader who is not signed in.
    Take both runs against the same database: what a reader looked at
    lately is kept per account, so rebuilding the database between the
    two changes it and fills the comparison with lines that mean
    nothing.

EOD;
}
/**
 * Used to clean trailing whitespace from files in a folder or just from
 * a file given in the command line. If also removes final ?> characters
 * to make php files conform with suggested coding guidelines. Similarly,
 * adds a space between if, for, foreach, etc and ( if not present to make
 * match PHP coding guidelines
 *
 * @param array $args $args[0] contains path to sub-folder/file
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function clean($args)
{
    global $num_spaces_tab;
    $no_instructions = false;
    if (isset($args[0])) {
        $path = realpath($args[0]);
        $no_instructions = true;
        mapPath($path, C\NS_EXEC . "cleanLinesFile");
    }
    return $no_instructions;
}
/**
 * Updates the copyright info (assuming in Yioop docs format) on files
 * in supplied sub-folder/file. That is, it changes strings matching
 * /2009 - \d\d\d\d/ to 2009 - current_year in those files/file.
 *
 * @param array $args $args[0] contains path to sub-folder/file
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function copyright($args)
{
    $no_instructions = false;
    if (isset($args[0])) {
        $path = realpath($args[0]);
        $year = date("Y");
        $out_year = "2009 - ".$year;
        replaceFile("", "/2009 \- \d\d\d\d/", $out_year, "change");
            // initialize callback
        mapPath($path, C\NS_EXEC . "replaceFile");
        $no_instructions = true;
    }
    return $no_instructions;
}
/**
 * longlines prints the line number and the line for every line past
 * eighty columns in the file or folder named. Yioop is written to eighty
 * columns, and this counts characters rather than bytes, so a line
 * carrying an accent or a symbol is measured as a reader sees it.
 *
 * @param array $args $args[0] contains path to sub-folder/file
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function longlines($args)
{
    global $change_extensions;
    $no_instructions = false;
    $change_extensions = array_diff($change_extensions, ["ini", "xml"]);
    if (isset($args[0])) {
        $path = realpath($args[0]);
        searchFile("", "/([^\n]){81}/u");// initialize callback
        mapPath($path, C\NS_EXEC . "searchFile");
        $no_instructions = true;
    }
    return $no_instructions;
}
/**
 * needsdocs reads the file or folder named and prints one line for every
 * class, method, property or constant with a docblock that is missing,
 * empty, or out of step with what it describes, then a tally by kind.
 * The reading itself is done by the DocblockChecker class.
 *
 * @param array $args $args[0] contains path to sub-folder/file
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function needsdocs($args)
{
    if (!isset($args[0])) {
        return false;
    }
    $path = realpath($args[0]);
    if (!$path) {
        echo "Path not found: {$args[0]}\n";
        return true;
    }
    $checker = new DocblockChecker();
    $checker->run($path);
    $checker->printSummary();
    return true;
}
/**
 * Performs a search and replace for given pattern in files in supplied
 * sub-folder/file
 *
 * @param array $args $args[0] contains path to sub-folder/file,
 *     $args[1] contains the regex searching for, $args[2] contains
 *     what it should be replaced with, $args[3] (defaults to effect)
 *     controls the mode of operation. One of "effect", "change", or
 *     "interactive". effect shows line number and lines matching pattern,
 *     but commits no changes; interactive for each match, prompts user
 *     if should do the change, change does a global search and replace
 *     without output
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function replace($args)
{
    $no_instructions = false;
    if (isset($args[0]) && isset($args[1]) && isset($args[2])) {
        $path = realpath($args[0]);
        $no_instructions = true;
        $pattern = $args[1];
        $replace = $args[2];
        $mode = (isset($args[3])) ? $args[3] : "effect";
        $len = strlen($pattern);
        if ($len >= 2) {
            $pattern = preg_quote($pattern,"@");
            $pattern = "@$pattern@";
            replaceFile("", $pattern, $replace, $mode); // initialize callback

            mapPath($path, C\NS_EXEC . "replaceFile");
        }
    }
    return $no_instructions;
}
/**
 * Performs a search for given pattern in files in supplied sub-folder/file
 *
 * @param array $args $args[0] contains path to sub-folder/file,
 *     $args[1] contains the regex searching for
 * @return bool $no_instructions false if should output CodeTool.php
 *     instructions
 */
function search($args)
{
    $no_instructions = false;
    if (isset($args[0]) && isset($args[1])) {
        $path = realpath($args[0]);
        $no_instructions = true;
        $pattern = $args[1];
        $len = strlen($pattern);
        if ($len >= 2) {
            $pattern = preg_quote($pattern, "@");
            $pattern = "@$pattern@";
            searchFile("", $pattern); // initialize callback
            mapPath($path, C\NS_EXEC . "searchFile");
        }
    }
    return $no_instructions;
}
/**
 * probe calls one activity of one controller once for each screen a file
 * names, and prints everything that activity hands its view.
 *
 * A long method is split by moving parts of it into methods of their own,
 * and the question that split has to answer is whether any screen it
 * serves now shows something different. Reading the two versions cannot
 * answer that. Calling the activity before the split and again after, and
 * comparing what it handed the view each time, can. Each line of the
 * screen file gives a name for the screen and the query a browser would
 * send. Values that differ between two runs of the same screen, such as
 * the reader's mark and the time, are written as TOKEN and TIME, so that
 * two runs of an unchanged activity match to the byte.
 *
 * @param array $args $args[0] names the controller, such as admin;
 *     $args[1] names the activity, such as wiki; $args[2] is the path of
 *     the file naming the screens
 * @return bool false where the arguments were not enough, which makes
 *     CodeTool.php print how it is used
 */
function probe($args)
{
    if (empty($args[0]) || empty($args[1]) || empty($args[2])) {
        return false;
    }
    list($controller_name, $activity, $screen_file) = $args;
    if (!file_exists($screen_file)) {
        echo "probe: no file at $screen_file\n";
        return true;
    }
    $controller_class = C\NS_CONTROLLERS . ucfirst($controller_name) .
        "Controller";
    if (!class_exists($controller_class)) {
        echo "probe: no controller called $controller_name\n";
        return true;
    }
    /* The link builders a view reaches for, such as the one that makes
       the address of a wiki page, are plain functions declared in
       src/index.php rather than methods on a class, so nothing loads them
       on their own. Under the command line that file declares them and
       runs nothing. */
    require_once __DIR__ . "/../index.php";
    $_SERVER["MOBILE"] = false;
    $_SERVER["NO_LOGGING"] = true;
    if (session_status() == PHP_SESSION_NONE) {
        session_start();
    }
    foreach (file($screen_file) as $line) {
        $line = trim($line);
        if ($line === "" || $line[0] == "#") {
            continue;
        }
        $parts = preg_split("/\s+/", $line, 2);
        $name = $parts[0];
        $query = $parts[1] ?? "";
        $as_public = false;
        if (strncmp($query, "public ", 7) == 0) {
            $as_public = true;
            $query = substr($query, 7);
        }
        $_SESSION = ($as_public) ? [] :
            ['USER_ID' => C\ROOT_ID, 'MAX_PAGES_TO_SHOW' => 10];
        $_REQUEST = [];
        parse_str($query, $_REQUEST);
        /* A screen line may name an activity of its own, and where it
           does that is the one asked for, so one screen file serves both
           this and devlog/drive_screens.js. */
        $_REQUEST['c'] = $_REQUEST['c'] ?? $controller_name;
        $_REQUEST['a'] = $_REQUEST['a'] ?? $activity;
        $_GET = $_REQUEST;
        $controller = new $controller_class();
        echo "===== $name =====\n";
        ob_start();
        $trouble = "";
        try {
            $shown = $controller->call($activity);
        } catch (\Throwable $caught) {
            $shown = null;
            $trouble = get_class($caught) . ": " . $caught->getMessage();
        }
        $said = ob_get_clean();
        if ($said !== "") {
            echo "output: " . probeSteady($said) . "\n";
        }
        if ($trouble !== "") {
            echo "threw: $trouble\n";
        }
        if (is_array($shown)) {
            ksort($shown);
            echo var_export(probeSteady($shown), true), "\n";
        }
    }
    return true;
}
/**
 * probeSteady writes over the parts of what an activity hands its view
 * that differ between two runs of the same screen, so that a comparison
 * shows only what a change made.
 *
 * probe calls this on everything it prints. The reader's mark, a long
 * run of letters and digits standing for a hash, and a count of seconds
 * are all different each run and would otherwise fill a comparison with
 * differences that mean nothing.
 *
 * @param mixed $what a value the activity handed its view, which may be
 *     an array holding further arrays
 * @return mixed the same value with those parts written over
 */
function probeSteady($what)
{
    if (is_array($what)) {
        $out = [];
        foreach ($what as $key => $value) {
            $out[$key] = probeSteady($value);
        }
        /* A list keeps the order it was built in, since that order is
           part of what a screen shows. A set of values kept under names
           of their own often comes out of the database in whatever order
           the rows arrived, so those are sorted by name; without that a
           comparison fills with lines that only moved. */
        $keys = array_keys($out);
        $named = ($keys !== [] && $keys !== range(0, count($keys) - 1));
        if ($named) {
            ksort($out);
        }
        return $out;
    }
    /* A count of seconds since 1970 falls in this range for any date
       between 2001 and 2033, and stands for a moment rather than for
       anything a screen shows, so it is written over. */
    if (is_int($what) && $what > 1000000000 && $what < 2000000000) {
        return "TIME";
    }
    if (is_string($what)) {
        $what = preg_replace('/[a-f0-9]{20,}/', "HASH", $what);
        $what = preg_replace('/\b\d{10}\b/', "TIME", $what);
        $what = preg_replace('/' . C\p('CSRF_TOKEN') . '=[^&\'"\s]*/',
            "TOKEN", $what);
    }
    return $what;
}
/**
 * Used to run or list Yioop unit tests given in $args
 * @param array $args - if empty run all tests, if $args[0] == 'list'
 *  then list available test. If $args[0] is '-f' or '--filter' then
 *  $args[1] is a substring and every test class whose name contains
 *  it (case-insensitive) is run. If $args[0] == name_of_particular
 *  then run just that test. If $args[1] == name_of_particular case,
 *  then just run that test case of the particular test.
 * @return bool whether $args made sense so could process
 */
function unit($args)
{
    $tests_controller = new TestsController();
    $data = $tests_controller->listTests();
    $test_names = $data["TEST_NAMES"];
    if (!empty($args[0]) && $args[0] == "list") {
        if (!empty($args[1])) {
            return false;
        }
        echo "Yioop Unit Test List\n====================\n";
        foreach ($test_names as $test_name) {
            echo "$test_name\n";
        }
        return true;
    }
    $_SERVER["NO_LOGGING"] = true;
    if (!empty($args[0]) &&
        ($args[0] == "-f" || $args[0] == "--filter")) {
        /* run every test class whose name contains the filter
           string (case-insensitive), reusing the same filtering
           the tests web interface applies. */
        if (empty($args[1]) || !empty($args[2])) {
            return false;
        }
        $_REQUEST['filter'] = $args[1];
        $data = $tests_controller->runSelectedTests();
        $data["ALL_RESULTS"] = $data["TEST_RESULTS"];
    } else if (empty($args[0])) {
        $data = $tests_controller->runSelectedTests();
        $data["ALL_RESULTS"] = $data["TEST_RESULTS"];
    } else {
        if (!empty($args[2])) {
            return false;
        }
        if (!in_array($args[0], $test_names)) {
            echo "Test Class: {$args[0]} not found\n!";
            return true;
        }
        $_REQUEST['test'] = $args[0];
        if (!empty($args[1])) {
            if (method_exists(C\NS_TESTS . $args[0], $args[1])) {
                $_REQUEST['method'] = $args[1];
            } else {
                echo "Test Class: {$args[0]} method {$args[1]} not found\n!";
                return true;
            }
        }
        $data = $tests_controller->runTest();
        $data["ALL_RESULTS"] = [$_REQUEST['test'] => $data["RESULTS"]];
    }
    echo "\n\nYioop Unit Tests\n================\n\n";
    foreach ($data['ALL_RESULTS'] as
        $test_class_name => $test_methods) {
        echo $test_class_name . "\n" .
            str_pad("", strlen($test_class_name), "-") . "\n";
        if (!empty($test_methods['JS'])) {
            echo "JavascriptUnitTests not supported from command line!\n";
            continue;
        }
        foreach ($test_methods as $method_name => $method_time_tests) {
            list($method_tests, $elapsed_time) = $method_time_tests;
            $elapsed_time = sprintf("%.5e", $elapsed_time);
            $passed = 0;
            $count = 0;
            $failed_items = [];
            foreach ($method_tests as $item) {
                if ($item['PASS']) {
                    $passed++;
                } else {
                    $failed_items[] = $item;
                }
                $count++;
            }
            $summary = "[$passed / $count] passed";
            echo " $method_name: " .
                unitResultColor($summary, $passed === $count) .
                ". Time: {$elapsed_time}s.\n";
            if ($passed < $count) {
                foreach ($failed_items as $item) {
                    echo "  FAILED: ".$item['NAME']."\n";
                }
            }
        }
    }
    return true;
}
/**
 * unitResultColor wraps a short line of test results in the codes that
 * show it in green where every test in the group passed and in red where
 * one did not. The codes are added only where the output is going to a
 * terminal a person is watching, so a log file or a pipe gets plain
 * text. On Windows the terminal's handling of these codes is switched on
 * first, so the same codes work there.
 *
 * @param string $text the status text to color, e.g. "[3 / 3] passed"
 * @param bool $all_passed whether every test in the group passed
 * @return string the text wrapped in color codes when the terminal
 *      supports them, otherwise the text unchanged
 */
function unitResultColor($text, $all_passed)
{
    static $supported = null;
    if ($supported === null) {
        $supported = defined('STDOUT') &&
            function_exists('stream_isatty') && stream_isatty(STDOUT);
        if ($supported && strncasecmp(PHP_OS, 'WIN', 3) === 0) {
            $supported = function_exists('sapi_windows_vt100_support') &&
                sapi_windows_vt100_support(STDOUT, true);
        }
    }
    if (!$supported) {
        return $text;
    }
    $green = "\033[32m";
    $red = "\033[31m";
    $reset = "\033[0m";
    return ($all_passed ? $green : $red) . $text . $reset;
}
/**
 * cleanLinesFile tidies one file. The clean command hands it every file
 * under the folder being walked. Tabs become four spaces, whitespace at
 * the end of a line goes, a closing php mark at the end of a file goes,
 * and a space is put between a word such as if or foreach and the
 * bracket after it.
 *
 * @param string $filename name of file to clean lines for
 */
function cleanLinesFile($filename)
{
    global $change_extensions;
    global $num_spaces_tab;
    $spaces = str_repeat(" ", $num_spaces_tab);
    $path_parts = pathinfo($filename);
    $extension = $path_parts['extension'];
    if (!excludedPath($filename) && in_array($extension, $change_extensions)) {
        $lines = file($filename);
        $out_lines = [];
        $change = false;
        $i = 0;
        foreach ($lines as $line) {
            $new_line = preg_replace("/\t/", $spaces, $line);
            $count = 0;
            $new_line = preg_replace('/(if|elseif|else|switch|case|".
                "while|foreach|for|catch)\(/', "$1 (", $new_line);
            $new_line = rtrim($new_line);
            $out_lines[] = $new_line;
            if (strcmp($new_line."\n", $line) != 0) {
                $change = true;
            }
            $i++;
        }
        $last_line = $i - 1;
        if ($new_line == '?>') {
            $change = true;
            $out_lines[$last_line] = "\n";
        }
        $out_file = implode("\n", $out_lines);
        if ($change) {
            file_put_contents($filename, $out_file);
        }
    }
}
/**
 * boldText wraps text in the terminal codes that show it in bold, and
 * only where the output is a terminal a person is watching. On a Windows
 * console the terminal's handling of these codes is switched on first.
 * Where the output goes to a file, or the console does not know the
 * codes, the text comes back as it was, so no stray control characters
 * are written. This is what lets the search command show a match in bold
 * without spoiling a log.
 *
 * @param string $text text to show in bold
 * @return string the text wrapped in bold codes, or unchanged when
 *     bold is not supported
 */
function boldText($text)
{
    static $supported = null;
    if ($supported === null) {
        $supported = false;
        if (function_exists("stream_isatty") && stream_isatty(STDOUT)) {
            if (DIRECTORY_SEPARATOR === "\\") {
                $supported =
                    function_exists("sapi_windows_vt100_support") &&
                    sapi_windows_vt100_support(STDOUT, true);
            } else {
                $supported = true;
            }
        }
    }
    if (!$supported) {
        return $text;
    }
    return "\033[1m" . $text . "\033[0m";
}
/**
 * searchFile looks through one file for a pattern. The search command
 * hands it every file under the folder being walked. For each line that
 * matches it prints the line number and a short piece of the line: the
 * text that matched, in bold, with up to thirty characters either side
 * of it and dots to show where the piece was cut.
 *
 * @param string $filename name of file to search in
 * @param mixed $set_pattern if not false, then sets $set_pattern in $pattern to
 *     initialize the callback on subsequent calls. $pattern here is the
 *     search pattern
 */
function searchFile($filename, $set_pattern = false)
{
    global $change_extensions;
    static $pattern = "/";
    if ($set_pattern) {
        $pattern = $set_pattern;
    }
    $path_parts = pathinfo($filename);
    if (!isset($path_parts['extension'])) {
        return;
    }
    $extension = $path_parts['extension'];
    if (!excludedPath($filename) && in_array($extension, $change_extensions)) {
        $file_data = file_get_contents($filename);
        if (preg_match_all($pattern, $file_data, $matches, PREG_OFFSET_CAPTURE)
            == false) {
            return;
        }
        $no_output = true;
        foreach ($matches[0] as $match) {
            $offset = $match[1];
            $num = substr_count($file_data, "\n", 0, $offset);
            if ($no_output) {
                $no_output = false;
                echo "\nIn $filename:\n";
            }
            $match_text = $match[0];
            $left_start = max(0, $offset - SEARCH_CONTEXT_CHARS);
            $left = substr($file_data, $left_start,
                $offset - $left_start);
            $right = substr($file_data, $offset + strlen($match_text),
                SEARCH_CONTEXT_CHARS);
            $left = str_replace(["\r", "\n"], " ", $left);
            $right = str_replace(["\r", "\n"], " ", $right);
            echo "  Line $num: ..." . $left . boldText($match_text) .
                $right . "...\n";
        }
    }
}
/**
 * replaceFile writes one pattern over another in a single file. The
 * replace command hands it every file under the folder being walked.
 * What it does with a line that matches depends on the mode: it may
 * print what the line would become and change nothing, ask about each
 * line one at a time, or write every change without asking.
 *
 * @param string $filename name of file to search and replace in
 * @param mixed $set_pattern if not false, then sets $set_pattern in $pattern to
 *     initialize the callback on subsequent calls. $pattern here is the
 *     search pattern
 * @param mixed $set_replace if not false, then sets $set_replace in $replace to
 *     initialize the callback on subsequent calls.
 * @param mixed $set_mode if not false, then sets $set_mode in $mode to
 *     initialize the callback on subsequent calls.
 */
function replaceFile($filename, $set_pattern = false,
    $set_replace = false, $set_mode = false)
{
    global $change_extensions;
    static $pattern = "/";
    static $replace = "";
    static $mode = "effect";

    $pattern = ($set_pattern) ? $set_pattern : $pattern;
    $replace = ($set_replace) ? $set_replace : $replace;
    $mode = ($set_mode) ? $set_mode : $mode;

    $path_parts = pathinfo($filename);
    if (!isset($path_parts['extension'])) {
        return;
    }
    $extension = $path_parts['extension'];
    if (!excludedPath($filename) && in_array($extension, $change_extensions)) {
        $lines = file($filename);
        $out_lines = "";
        $no_output = true;
        $silent = false;
        if ($mode == "change") {
            $silent = true;
        }
        $num = 0;
        $change = false;
        foreach ($lines as $line) {
            $num++;
            $new_line = $line;
            if (preg_match($pattern, $line)) {
                if ($no_output && !$silent) {
                    $no_output = false;
                    echo "\nIn $filename:\n";
                }
                $new_line = preg_replace($pattern, $replace, $line);
                if (!$silent) {
                    echo "  Line $num: $line";
                    echo "  Changes to: $new_line";
                }
                if ($mode == "interactive") {
                    echo "Do replacement? (Yy - yes, anything else no): ";
                    $confirm = strtolower(readInput());
                    if ($confirm != "y") {
                        $new_line = $line;
                    }
                }
                if (strcmp($new_line, $line) != 0) {
                    $change = true;
                }
            }
            $out_lines .= $new_line;
        }
        if (in_array($mode, ["change", "interactive"])) {
            if ($change) {
                file_put_contents($filename, $out_lines);
            }
        }
    }
}
/**
 * mapPath calls a function once for each file under a path. Where the
 * path names a folder, every file beneath it is walked; where it names
 * one file, the function is called once. Each command of this tool works
 * by handing its own per-file function here.
 *
 * @param string $path to apply map $callback to
 * @param string $callback function name to call with filename of each file
 *     in path
 */
function mapPath($path, $callback)
{
    global $db;
    if (is_dir($path)) {
        $db->traverseDirectory($path, $callback, true);
    } else {
        $callback($path);
    }
}
/**
 * excludedPath says whether a path is one this tool passes over. Hidden
 * folders and the folder holding code from elsewhere are passed over, so
 * a command does not rewrite files that are not Yioop's to rewrite.
 *
 * @param $path a directory path
 * @return bool whether or not it should be ignored (true == ignore)
 */
function excludedPath($path)
{
    global $exclude_paths_containing;

    foreach ($exclude_paths_containing as $exclude) {
        if (strstr($path, $exclude)) {
            return true;
        }
    }
    return false;
}
X