writing command line scripts in php: part 3; interactive input
Tags: php
Published: 2022-04-08 22:11:29

there's not a tremendous amount of documentation on writing command line scripts in php, and if you're looking to build an interactive script that leverages your existing php code, the process can be frustrating. this series of posts is designed to cover the basic constructs we will need to write effective interactive scripts in php.

this installment focuses on getting user input interactively.

## previous installments
this is the third installment in the series. previously, we have covered hadling arguments, preflighting, reading from STDIN on piped input and more.

the articles that compose this series (so far) are:

- [pt 1. arguments, preflights and more](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-1-3jpb)
- [pt 2. handling STDIN input](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-2-reading-stdin-2enf)
- [pt 3. interactive input](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34)

## the flyover
we will be handling interactive user input in this installement. basically, this is just a fancy term for getting a line of text input from our users. very handy stuff!

we'll be covering:

- reading a line of user input
- adding up and down history scrolling 
- add autocomplete on tab
- overriding the screen echo when the user types, ie. for password input. this is more advanced.
- fixing our password input so `<BACKSPACE>` works

these last two parts &mdash; building a password input and fixing it &mdash; are here mostly as a way to illustrate points about how php interacts with streams and how to handle ascii chars effectively.

as usual, all the examples here should be preceeed by the php 'shebang':

```php
#!/usr/bin/env php
```

## reading a line of user input
reading a line of input from our user is simple. in fact, php has a built-in command to do just that, [`readline()`](https://www.php.net/manual/en/function.readline.php).

let's use it in our fancy script:

```php
/**
 * read a line of input from the user with an optional prompt
 * returns the line as a string or, if the user hits ^D, boolean false
 */
function get_user_input($prompt = null) {
    $line = readline($prompt);
    return $line;
}

/**
 * entry point
 */
$line = get_user_input("enter something: ");
print $line.PHP_EOL;
```

this is not particularly complex; our function serves as nothing more than a wrapper for php's `readline()` here.

when we run this, we discover that `readline()` stops execution of our script and waits for user input. when the user hits `<RETURN>`, the input is done and `readline()` returns it.

of note here is that if the user hits `^D`, the value returned is boolean `false`. we can choose to handle that and return `null` instead to maintain some approximation of type consistency if we wish.

```php
/**
 * read a line of input from the user with an optional prompt
 * returns the line as a string.
 */
function get_user_input($prompt = null) {
    $line = readline($prompt);
    return $line ? $line : null; // no boolean return 
}
```

`readline` is a great function: it handles backspace, the left and right arrow keys, insert mode; basically all the features you want in function that reads a line of user input.

## adding history scrolling
taking a simple line of user input is probably 90% of what we will ever want to do, but `readline` is capable of a lot more. 

next we're going to implement a traversable history for multi-line user input. if you use a shell like `bash` (or, better still, [`fish`](https://fishshell.com/)) and use the up and down arrows to navigate your history, this is the feature we're going to approximate.

implementing this feature only requires one new command: `readline_add_history()`.

let's look at a sample implementation:

```php
/**
 * read an arbitrary number of lines of input with history navigation
 * ^D to quit
 */
while (true) {
    // read the line
    $line = readline();

    // test for ^D and break loop if we get it
    if ($line === false) {
        break;
    }

    // add line to history file for navigation
    readline_add_history($line);
}

// output our history to inspect
print_r(readline_list_history());
```

we notice here that we have an infinite loop with `while(true)` that breaks when `readline()` returns false. as we found in the previous section, `readline()` gives us a boolean false when the user hits `ctrl-D`.

after we read the user's line of input, we append it to a file so that the user can scroll up and down their input history, using `readline_add_history()`. this is basically the same mechanism as the `.bash_history` file and it works similarly. if we want to, we can specify a file path as an argument to `readline_add_history()`, but it's not necessary.

when the loop exits, we dump the entire history to the terminal using `readline_list_history()`. this returns an array of all the lines entered by the user.

## add autocomplete on tab
autocomplete on tab is pretty great; it saves keystrokes and avoids annoying typing errors. let's investigate how we can add that to our function that gets user input.

```php
/**
 * the completion function
 * @return Array
 */
function completion_function($line, $index) {
    return [
        "Johnny Thunders",
        "Tom Verlaine",
        "David Johansson",
        "Deborah Harry",
    ];
}

// set the completion function
readline_completion_function('completion_function');

// read user input with tab complete
$name = readline("enter a name: ");
```

we can see that this script does two things:

- creates a completion function that returns an array of values
- registers that completion function with `readline()` using `readline_completion_function()`

our 'completion function' is nothing more than a function that returns an array of potential values. once it is registered with `readline()`, it is called every time the user hits `<TAB>`. if the last characters after a space of the user's input matches the starting characters of any of the elements of the array, those values are returned to `readline()` and autocompleted.


## read a password, echoing stars
nobody wants casual passersby shoulder-surfing their valuable data, so being able to construct a working 'dots echo' feature for user input is a valuable thing to have.

unfortunately, it's not possible to build this with `readline()`; the echo-to-screen feature is baked right in. in order to create a secure password entry function, we'll need read our users' input manually.

this takes a bit more effort, but on the plus side it gives us the opportunity to explore things like ascii, ansi commands and streams.

let's take a look at this password entry function.

```php
/**
 * Read one line of dots-echo user input
 *
 * @return String
 */
function get_user_input_password()
{
    /**
     * a buffer to hold user keystrokes
     */
    $userline = [];

    /**
     * overwrite the readline handler to do nothing
     */
    readline_callback_handler_install("", function () {});

    /**
     * read user input until <return>
     */
    while (true) {
        /**
         * read user keystroke from STDIN
         */
        $keystroke = stream_get_contents(STDIN, 1);

        /**
         * on <return> break.
         * ascii 10 = line feed
         */
        if(ord($keystroke) == 10) {
            break;
        }

        /**
         * add user keystroke to buffer
         * output '*' in place of entered character
         */
        else {
            $userline[] = $keystroke;
            fwrite(STDOUT, "*");
        }

    }

    return join($userline);
}

/**
 * entry point
 */
$userpassword = get_user_input_password();
print "we got user password:".PHP_EOL;
print $userpassword.PHP_EOL;
```

there's a lot going on here, and this function is very different than the ones we've made so far using `readline()`.

the general gist here is:

- disable echoing user's keystrokes to screen
- catch each user keystroke, one character at a time, and add to a buffer array
- echo an asterisk in place of each character
- on `<RETURN>` stop taking character input
- return the array, joined as a string

let's investigate that in some more detail.

the first thing we do is call `readline_callback_handler_install()`, passing it an empty function. this, as the name implies, sets the function that get calls by `readline()` on each read. by passing an empty function here, we're going to supress output.

next, we're going to build an infinite loop and in that loop, we'll place a call to `stream_get_contents(STDIN, 1)` which will read one byte of input. 

in the previous installment of this series, we read piped input from `STDIN`. this is similar except, instead of getting an arbitrary amount of data that was available on script launch, we're waiting for input to become available (as the user types) and accepting it one character at a time. `stream_get_contents()` takes two arguments: the stream (`STDIN`) and the number of bytes to read (1).

combining the loop and `stream_get_contents()` allows us to read and process an arbitrary amount of data one byte at a time.

we will be adding each character read to a buffer but, before that, we will want to test for whether or not the user has pressed the `<RETURN>` key. we do this test by calling [`ord()`](https://www.php.net/manual/en/function.ord) on the character.

simply put, `ord()` converts a character to it's corresponding one-byte number. in ascii, which is the character encoding we're assuming here, that translates to the character's ascii code. if we take a look at [an ascii table](https://www.rapidtables.com/code/text/ascii-table.html), we will see that a 'line feed' is code 10. when we say `<RETURN>`, what we're *really* saying is either 'line feed' (all operating systems except windows) or 'carriage return and then line feed' (windows). either way, ascii code 10, the 'line feed', is the character we are looking for.

once we have determined that the user has hit `<RETURN>`, we call `break()` to end our infinite loop and return our user input. if the user doesn't hit `<RETURN>`, we add the character to the buffer and start our loop again.

the last component of this function is echoing the asterisk (the whole reason we're building this!). rather than use `echo` directly, we're going to `fwrite()` directly to `STDOUT`. this is a good illustration of how streams work. here we're treating `STDOUT` exactly the same as we would a file pointer, except instead of the output going to an `fopen()`ed file, it goes to the terminal.

once we have this written, we can run it and our input is sanitized to asterisks on output.

## fix dot echo backspace issue
except, there's a problem.

when we hit `<BACKSPACE>`, instead of erasing the character behind the cursor, we get just another character. that's not great.

the reason for this is that we are reading in each character uncritically and sticking it in the buffer. the only character that we're handling with a special case is `<RETURN>`. in essence, when the user hits `<BACKSPACE>`, we're just adding a `<BACKSPACE>` character to the buffer.

to fix this, we will need to test for `<BACKSPACE>` the same way we do for `<RETURN>`.

let's look at our function with this new feature added.

```php
define('BACKSPACE', chr(8));
define('ERASE_TO_END_OF_LINE', "\033[0K");

/**
 * Read one line of user input
 *
 * @return String
 */
function get_user_input_password_backspace()
{
    /**
     * a buffer to hold user keystrokes
     */
    $userline = [];

    /**
     * overwrite the readline handler to do nothing
     */
    readline_callback_handler_install("", function () {
    });

    /**
     * read user input until <return>
     */
    while (true) {
        /**
         * read user keystroke from STDIN
         */
        $keystroke = stream_get_contents(STDIN, 1);

        /**
         * on <return> break.
         * ascii 10 = line feed
         */
        if (ord($keystroke) == 10) {
            break;
        }

        /**
         * handle backspace.
         */
        elseif (ord($keystroke) == 127) {
            // remove last char from buffer
            array_pop($userline);
            // back up cursor one space
            fwrite(STDOUT, BACKSPACE);
            // delete to the end of the line
            fwrite(STDOUT, ERASE_TO_END_OF_LINE);
        }

        /**
         * add user keystroke to buffer
         * output '*' in place of entered character
         */
        else {
            $userline[] = $keystroke;
            fwrite(STDOUT, "*");
        }
    }

    return join($userline);
}

/**
 * entry point
 */
$userpassword = get_user_input_password_backspace();
print "we got user password:".PHP_EOL;
print $userpassword.PHP_EOL;
```

before we start going over the changes to this function, we're going to cover the concept of [ansi escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code).

ansi codes are, basically, commands that you can issue to the terminal to do things with the cursor or the screen; erase lines, move the cursor to the beginning of the line, stuff like that. since when the user hits `<BACKSPACE>` what happens is basically "back the cursor up one space and erase to the end of the line", ansi codes are tool we need.

all ansi codes start with `\`, the escape character. with that in mind, let's look at this `define` statement at the top of our script:

```php
define('ERASE_TO_END_OF_LINE', "\033[0K");
```

this constant is the command to erase all characters from the cursor to the end of the line. 

the first part of the value, `\033` is the 'escape' character. if we look at our [ascii table](https://www.rapidtables.com/code/text/ascii-table.html), we see that 'escape' has the code 27. it's written here as '033' because we are using [octal](https://www.cuemath.com/numbers/octal-number-system/), and decimal 27 is 033 in octal. all octal numbers are preceeded by a '0' so that our system can identify them as octal and not, say, decimal or hexadecimal.

the remaining `[0K` is the ansi command part itself. there are a lot of ansi commands that do all sorts of things, but some of the popular ones are:

| ansi code | description                               | ascii       |
| --------- | ----------------------------------------- | ----------- |
| `\[0J` 	    | erase from cursor until end of screen     | 27,91,48,74 |
| `\[1J` 	    | erase from cursor to beginning of screen  | 27,91,49,74 |
| `\[2J` 	    | erase entire screen                       | 27,91,50,74 |
| `\[0K` 	    | erase from cursor to end of line          | 27,91,48,75 |
| `\[1K` 	    | erase start of line to the cursor         | 27,91,49,75 |
| `\[2K` 	    | erase the entire line                     | 27,91,50,75 |

we'll notice in that table there there is also an 'ascii' column. this is the list of ascii codes for each of the characters in the ansi escape code, so ie. for `\[0k`, the ansi for the escape character is 27, the `[` bracket is 91, '0' is 48 and 'K' is 75.

### an amusing story about octal you can skip
back in the early days of my career, i was using a proprietary third-party library that dispalyed a peculiar and alarming behaviour. values that went in as strings of integers and started with a zero were later returned as completely different integers. ie. the string "00648" became the integer 1210. this resulted in a lot of problems and was the source of much complaint and speculation.

being something of keener at the time, rather than just raise a support ticket with the vendor, i decided to churn through a list of input/output pairings to see if a pattern emerged. the bug, that the library was casting to integer and converting from octal to decimal, became apparent when i saw "031" translate to 25 and recalled the classic joke "why do programmers always confused halloween with christmas? because oct 31 and dec 25 are the same."


### returning to the dots-echo with working backspace
now that we have a grip on ansi escape codes, let's move on to analyzing the function.

the first thing we notice are a pair of `define()` statements at the top.

```php
define('BACKSPACE', chr(8));
define('ERASE_TO_END_OF_LINE', "\033[0K");
```
we've already covered `ERASE_TO_END_OF_LINE`, so let's look at `BACKSPACE`. 

since we want to be able to test if our user has pressed the `<BACKSPACE>` key, we need to know how to represent it in our code. unlike other characters, we can't just type it into a string, so we are going to get the character by taking it's ascii code and converting it into a character. looking at our [ascii table](https://www.rapidtables.com/code/text/ascii-table.html), we see that correct ascii code is 8. we then use `chr()` to convert the ascii code into an actual character. note that `chr()` is the inverse of `ord()`; `ord()` converts a character to an ascii code, and `chr()` converts it back.

the next addition of interest is the `elseif()` block in the infinte loop.

```php
/**
 * handle backspace.
 */
elseif (ord($keystroke) == 127) {
    // remove last char from buffer
    array_pop($userline);
    // back up cursor one space
    fwrite(STDOUT, BACKSPACE);
    // delete to the end of the line
    fwrite(STDOUT, ERASE_TO_END_OF_LINE);
}
```

when you hit the `<BACKSPACE>` key on the keyboard, the ascii code of the resulting character is 127; that's the character we want to trap and handle. we test for it using `ord()`.

in the `elseif` block, we then do three things:

- remove the last character from the buffer array with `array_pop()` so that it is no longer part of the user input
- echo the `BACKSPACE` ansi command to the terminal so we move the cursor back one space
- echo the `ERASE_TO_END_OF_LINE` ansi command to the terminal so we delete the character we backed over

the result is a working `BACKSPACE` functionality.

## putting it all together
for most of our requirements, functions using `readline()` will be sufficient. manually reading from `STDIN` and writing to `STDOUT` with tests for various character codes will only be needed rarely, but it is good to have some understanding of how the terminal works and be familiar with ansi and ascii. in the future, we will be using more ansi codes and accessing other terminal features, like stty, in our scripts, so we will need to be comfortable with the core ideas.

## next steps
there's still some work for us to do with getting user input. going forward, we will be looking at key down events and how to use them for things ranging from 'hit any key' to building simple menu selections.