php

writing command line scripts in php: part 3; interactive input

gbhorwood

grant horwood

Posted on April 8, 2022

writing command line scripts in php: part 3; interactive input

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:

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 — building a password input and fixing it — 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':

#!/usr/bin/env php
Enter fullscreen mode Exit fullscreen mode

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().

let's use it in our fancy script:

/**
 * 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;
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * 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 
}
Enter fullscreen mode Exit fullscreen mode

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) 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:

/**
 * 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());
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * 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: ");
Enter fullscreen mode Exit fullscreen mode

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.

/**
 * 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;
Enter fullscreen mode Exit fullscreen mode

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() 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, 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.

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;
Enter fullscreen mode Exit fullscreen mode

before we start going over the changes to this function, we're going to cover the concept of ansi escape codes.

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:

define('ERASE_TO_END_OF_LINE', "\033[0K");
Enter fullscreen mode Exit fullscreen mode

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, we see that 'escape' has the code 27. it's written here as '033' because we are using octal, 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.

define('BACKSPACE', chr(8));
define('ERASE_TO_END_OF_LINE', "\033[0K");
Enter fullscreen mode Exit fullscreen mode

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, 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.

/**
 * 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);
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
gbhorwood
grant horwood

Posted on April 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related