Re: [RFC v4] edit: Add basic support for input line editing

Grant Erickson <[email protected]> Sun, 31 Mar 2024 09:45:40 -0700
Newsgroups dev.linux.lists.ell
Message-ID <[email protected]>
On Dec 22, 2023, at 2:14 PM, Marcel Holtmann <[email protected]> wrote:
> 
> This allows for simple line editing with history capabilities. On
> purpose this has no concept of terminal input or terminal output and
> just allows manipulation of an internal wide character string.
> 
> The debug option is something that might need to be removed or at least
> changed a little bit, but right now it is nice to see the internal
> states.
> 
> Following features are missing:
> 
> 1) Work with words (delete, move etc.)
> 2) Tab completion
> 3) Hints system
> 
> Following features are left to the user:
> 
> 1) Showing the prompt
> 2) Switching to masked input
> 
> The demo-edit is just for demonstration purposes and requires Curses to
> be available without any autoconf magic.
> 
> With demo-cli there is a really simple command line handling example
> that uses an internal terminal abstraction.

Marcel:

Thank you again for pulling this together. This looks to be in strong shape for production use. This certainly bests libedit/editline for being non-blocking, readline for licensing friendliness, and linenoise for being far more customizable and flexible.

I’ve encountered a few areas that I think could improve use and integration along with a few unknowns that I’ve yet been able to track down, with or without analysis tools.

Happy to post a full diff against v4 if that’s helpful.

1. The fatal errors from demo-cli and demo-edit are probably best as warnings since not having a UTF-8 locale (my system has the C and POSIX locales but that’s it) does not seem fatal as long as the application is restricted to ASCII (and even then, it still may not be fatal):

    if (strcmp(nl_langinfo(CODESET), ENCODING_UTF8)) {
        fprintf(stderr, "WARNING: no %s codeset / character map; non-ASCII characters may not work\n", ENCODING_UTF8);
    }

2. Other than introspection functions, ideally all of the functions that can have would have an ‘int’ or ‘int32_t’ return signature and return ‘0’ on success or ‘-<some POSIX error>’ on failure. With the Booleans, I find I have to synthesize an error any way for my application. It’d be easier to just to pass along whatever error l_edit or l_term encountered.

3. Ideally, all callbacks would take an instance pointer as their first argument, for example:

    typedef void (*l_edit_display_func_t) (struct l_edit *edit, const wchar_t *wstr, size_t wlen, size_t pos, void *user_data);

    typedef void (*l_term_key_func_t) (struct l_term *term, wint_t wch, void *user_data);

4. Ideally, the following would be public functions:

    int l_term_set_input(struct l_term *term, int fd);
    int l_term_set_output(struct l_term *term, int fd);

My application already has weak pointers to ’stdin’ and ’stdout’. I am able to use the above to just do:

    l_term_set_input(fileno(my_input_stream));
    l_term_set_output(fileno(my_output_stream));

5. Ideally, l_term would be more run loop adaptable. Toward that end, I would propose:

    typedef void (*l_term_io_func_t)(struct l_term *term,
                                     int fd,
                                     bool readable,
                                     bool writable,
                                     void *user_data);

    int l_term_set_io_handler(struct l_term *term,
                               l_term_io_func_t handler,
                               void *user_data);

with the following implementation added to / changed in term.c:

    #define IO_HANDLER(term, fd, readable, writable) \
        do {                                         \
            if (term && term->io_handler) {          \
                term->io_handler(term,               \
                                 fd,                 \
                                 readable,           \
                                 writable,           \
                                 term->io_data);     \
            }                                        \
        } while (0);

This is invoked with:

    IO_HANDLER(term, term->in_fd, 1, 0);
    IO_HANDLER(term, term->out_fd, 0, 1);

at the end of l_term_open and:

    IO_HANDLER(term, term->in_fd, 0, 0);
    IO_HANDLER(term, term->out_fd, 0, 0);

at the start of l_term_close. Both in lieu of the existing l_io calls.

It is also used in the newly-proposed l_term_set_input and l_term_set_output calls below.

    LIB_EXPORT int l_term_set_io_handler(struct l_term *term,
                    l_term_io_func_t handler, void *user_data)
    {
        if (!term)
            return -EINVAL;
    
        term->io_handler = handler;
        term->io_data = user_data;
    
        return 0;
    }
    
    LIB_EXPORT int l_term_set_input(struct l_term *term, int fd)
    {
        if (!term)
            return -EINVAL;
    
        if (fd < 0)
            return -EBADF;
    
        term->in_fd = fd;
        term->in_ops = NULL;
    
        IO_HANDLER(term, fd, 1, 0);
    
        return 0;
    }
    
    LIB_EXPORT int l_term_set_output(struct l_term *term, int fd)
    {
        if (!term)
            return -EINVAL;
    
        if (fd < 0)
            return -EBADF;
    
        term->out_fd = fd;
        term->out_ops = NULL;
    
        IO_HANDLER(term, fd, 0, 1);
    
        return 0;
    }
    
    LIB_EXPORT bool l_term_set_input_stdin(struct l_term *term)
    {
        return l_term_set_input(term, STDIN_FILENO) == 0;
    }
    
    LIB_EXPORT bool l_term_set_output_stdout(struct l_term *term)
    {
        return l_term_set_output(term, STDOUT_FILENO) == 0;
    }
    
Finally, I propose ‘l_term_process’ (or whatever it might be appropriate to name it as):

    LIB_EXPORT void l_term_process(struct l_term *term)
    {
        wchar_t wstr[2];
        ssize_t len;
        mbstate_t ps;
    
        if (!term)
            return;
    
        len = read(term->in_fd, term->key_buf + term->key_len,
                        sizeof(term->key_buf) - term->key_len);
        if (len < 0)
            return;
    
        term->key_len += len;
    
        while (term->key_len > 0) {
            memset(&ps, 0, sizeof(ps));
    
            len = mbrtowc(wstr, term->key_buf, term->key_len, &ps);
            if (len < 0)
                break;
    
            memmove(term->key_buf, term->key_buf + len,
                            term->key_len - len);
            term->key_len -= len;
    
            if (term->key_handler) {
                wint_t wch = wstr[0];
                term->key_handler(term, wch, term->key_data);
            }
        }
    }
    
which is effectively the main body of ‘in_callback’ which could be preserved and made public as an adjunct helper function for applications that want “all-in” on ELL:

    LIB_EXPORT bool l_term_io_callback(struct l_io *io, void *user_data)
    {
        struct l_term *term = user_data;
        
        l_term_process(term);
    
        return true;
    }

The addition of ‘l_term_set_bounds’:

    int l_term_set_bounds(struct l_term *term, uint16_t rows, uint16_t columns);

would likewise allow the bounds changing and SIGWINCH handling to be outside of l_term with a public helper that could / can be leveraged for those that want “all-in” on ELL. I haven’t fully completed that refactoring yet.

6. I suspect integrators will end up copy-and-pasting the whole of handle_input from demo-cli frequently which is mostly an emacs-style key binding “default". That is certainly where I started with hooks added for handling LF (basically “handle input”, the crux of the application) and Ctrl-C (which stops and quits my run loop). I’ve not yet forecast the right API, but it seems like there is a l_edit_emacs_handler or some such that can be set with a companion input_handler and control_handler or some such which pass along the input line and control sequence for application-specific handling.

7. l_edit_set_max_display_length unsurprisingly calls update_display which calls display_handler. Consequently, calling l_edit_set_max_display_length from display_handler creates an infinite loop. A warning comment probably serve future integrators well.

8. Finally, there seems to be a memory smasher somewhere. When I integrate l_edit and l_term, my application crashes during initialization with heap corruption sometime after I do initialization which amounts to:

    status_t ElementController :: InitEll(
        FILE *inInputStream,
        FILE *inOutputStream,
        FILE *inErrorStream
    )
    {
        struct l_term *  lEllTerm = nullptr;
        struct l_edit *  lEllEdit = nullptr;
        bool             lOpened  = false;
        bool             lStatus;
        status_t         lRetval  = STATUS_SUCCESS;
    
        nlREQUIRE_ACTION(inInputStream  != nullptr, done, lRetval = -EINVAL);
        nlREQUIRE_ACTION(inOutputStream != nullptr, done, lRetval = -EINVAL);
        nlREQUIRE_ACTION(inErrorStream  != nullptr, done, lRetval = -EINVAL);
    
        mDescriptorEventMap.clear();
    
        lEllTerm = l_term_new();
        nlREQUIRE_ACTION(lEllTerm != nullptr, done, lRetval = -ENOMEM);
    
        lEllEdit = l_edit_new();
        nlREQUIRE_ACTION(lEllEdit != nullptr, done, lRetval = -ENOMEM);
    
        lStatus = l_term_set_io_handler(lEllTerm, DescriptorCallback, this);
        nlREQUIRE_ACTION(lStatus, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        lStatus = l_term_set_key_handler(lEllTerm, KeyCallback, this);
        nlREQUIRE_ACTION(lStatus, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        lRetval = l_term_set_input(lEllTerm, fileno(inInputStream));
        nlREQUIRE_SUCCESS(lRetval, done);
    
        lRetval = l_term_set_output(lEllTerm, fileno(inOutputStream));
        nlREQUIRE_SUCCESS(lRetval, done);
    
        lOpened = l_term_open(lEllTerm);
        nlREQUIRE_ACTION(lOpened, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        lStatus = l_term_get_max_columns(lEllTerm, &mTermColumnsMax);
        nlREQUIRE_ACTION(lStatus, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        lStatus = l_edit_set_max_display_length(lEllEdit, mTermColumnsMax);
        nlREQUIRE_ACTION(lStatus, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        lStatus = l_edit_set_display_handler(lEllEdit, DisplayCallback, this);
        nlREQUIRE_ACTION(lStatus, done, lRetval = ERROR_INITIALIZATION_FAILED);
    
        mEllTerm = lEllTerm;
        mEllEdit = lEllEdit;
    
     done:
        if (lRetval < STATUS_SUCCESS)
        {
            if (lEllEdit != nullptr)
            {
                l_edit_free(lEllEdit);
            }
    
            if (lEllTerm != nullptr)
            {
                if (lOpened)
                    l_term_close(lEllTerm);
    
                l_term_free(lEllTerm);
            }
        }
        
        return (lRetval);
    }

If I return to using libedit/editline, it’s fine. If I just completely remove l_edit/l_term, it’s fine. Unfortunately and surprisingly, neither clang scan-build nor asan are able to zero in fully on the issue. Though, when it crashes on Armv7, asan indicates a read on 0x0 and often a corrupted PC and stack pointer of 0x0. My suspicion is there is a null or garbage dereference happening somewhere.

Best,

Grant

-- 
Principal
Nuovations

[email protected]
https://www.nuovations.com/