Download
562 lines 18.1 KiBCModified 2024-09-15 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
/* ask - a simple command line asker
 * Copyright 2019 Bruce Hill
 * Released under the MIT License (see LICENSE for details)
 * Usage: ask [-Q|--quickpick] [-y|--yes] [-n|--no] [-P|--password] [[-H |--history=]name]
 *   [-v|--version] [-h|--help] [-0|--read0] [-q |--query=initial] [[-p |--prompt=]prompt [options...]]
 *    --password: password mode
 *    --quickpick: quickpick mode (exit when only one option remains)
 *    --version: print version and exit
 *    --help: print usage and exit
 *    --read0: read input delimited by NULL bytes instead of newlines
 *    --query: initial value to pre-populate user input
 *    --prompt: use the given prompt (displayed in bold)
 *    --yes: append " [Y/n]" to the prompt, use quickpick mode, 
 *    --no: append " [y/N]" to the prompt, use quickpick mode, 
 *    --history: store the selected value in a history file, which can be browsed with
 *        up/down arrow keys
 */
#include <errno.h>
#include <limits.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/uio.h>
#include <termios.h>
#include <unistd.h>

#include "bterm.h"

#define ASK_VERSION "0.4"
#define LOWERCASE(c) ('A' <= (c) && (c) <= 'Z' ? ((c) + 'a' - 'A') : (c))
#define EQ(a, b) (case_sensitive ? (a) == (b) : LOWERCASE(a) == LOWERCASE(b))
#define PASSWORD "-\\|/"
#define MAX_HISTORY 1000

static int password = 0, quickpick = 0, case_sensitive = 0, histindex = 0, nhistory = 0;
static char histpath[PATH_MAX] = {0};

static inline void *memcheck(void *p)
{
    if (!p) {
        fprintf(stderr, "Memory allocation failure\n");
        exit(1);
    }
    return p;
}

/* Return length of the longest substring of patt[p:] that occurs within
 * str[s:], while still leaving enough space for the rest of `patt` to occur
 * within the rest of `str`. If there is no valid solution, return -1.
 */
static int lcs(const char *str, const char *patt, int slen, int plen, int s, int p, int *cache)
{
    if (!patt[p]) return 0;
    if (!str[s]) return -1;
    if (patt[p] == ' ') {
        if (str[s] == ' ') return lcs(str, patt, slen, plen, s+1, p+1, cache);
        else return lcs(str, patt, slen, plen, s, p+1, cache);
    }
    if (cache[s*plen + p]) return cache[s*plen + p];
    if (!EQ(str[s], patt[p])) return lcs(str, patt, slen, plen, s+1, p, cache);
    // Run starting here
    int run = 1;
    while (str[s+run] && patt[p+run] && EQ(str[s+run], patt[p+run])
           && lcs(str, patt, slen, plen, s+run, p+run, cache) >= 0) {
        ++run;
    }
    if (run == 0) run = -1;
    cache[s*plen + p] = run;
    return run;
}

static int matches(const char *str, const char *patt)
{
    while (*patt) {
        if (*patt == ' ') {
            ++patt;
            continue;
        }
        while (!EQ(*str, *patt)) {
            if (!*str) return 0;
            ++str;
        }
        ++str;
        ++patt;
    }
    return 1;
}

static int fputc_escaped(char c, FILE *out)
{
    static const char *escapes = "       abtnvfr             e";
    if (c > 0 && c <= '\x1b' && escapes[(int)c] != ' ') { // "\n", etc.
        fprintf(out, "\033[35m\\%c\033[37m", escapes[(int)c]);
        return 2;
    } else if (c >= 0 && !(' ' <= c && c <= '~')) { // "\x02", etc.
        fprintf(out, "\033[35m\\x%02X\033[37m", c);
        return 4;
    } else {
        fputc(c, out);
        return 1;
    }
}

static int draw_line(FILE *out, const char *option, const char *patt, int cursor)
{
    static const char *dim = "\033[22;2m", *bold = "\033[22;1m", *normal = "\033[22m";
    fputs(normal, out);
    const char *state = normal;
    const char *matchstyle = option ? bold : normal;
    if (!option) option = patt;
    size_t linelen = strlen(option), patlen = strlen(patt);
    int p = 0, run = 0;
    int *cache = calloc((linelen+1)*(patlen+1), sizeof(int));
    int backtrack = 0, to_start = 0;
    for (int i = 0; i < (int)linelen; i++) {
        if (EQ(patt[p], option[i]) &&
            run + lcs(option,patt,(int)linelen,(int)patlen,i,p,cache)
            >= lcs(option,patt,(int)linelen,(int)patlen,i+1,p,cache)) {
            if (state != matchstyle) {
                fputs(matchstyle, out);
                state = matchstyle;
            }
            int len = fputc_escaped(option[i], out);
            if (p >= cursor) backtrack += len;
            else to_start += len;
            ++run;
            ++p;
        } else if (patt[p] == ' ') {
            run = 0;
            ++p;
            --i;
        } else {
            run = 0;
            if (state != dim) {
                fputs(dim, out);
                state = dim;
            }
            int len = fputc_escaped(option[i], out);
            if (p >= cursor) backtrack += len;
            else to_start += len;
        }
    }
    if (backtrack) fprintf(out, "\033[0m\033[%dD", backtrack);
    free(cache);
    return to_start;
}

static void get_history(char **buf, size_t *cap, int index)
{
    if (nhistory == 0) return;
    if (index == nhistory) {
        (*buf)[0] = '\0';
        return;
    }
    FILE *f = fopen(histpath, "r");
    char histline[256];
    for (int i = 0; i < histindex; i++) {
        if (fgets(histline, sizeof(histline), f) == NULL)
            return;
        if (histline[strlen(histline)-1] != '\n')
            --i;
    }
    do {
        *buf = memcheck(realloc(*buf, (*cap += 100)));
        if (fgets(*buf, *cap, f) == NULL)
            return;
    } while ((*buf)[strlen(*buf)-1] != '\n');
    (*buf)[strlen(*buf)-1] = '\0';
}

/*
 * A basic fuzzy matcher and line inputter
 */
static char *get_input(FILE *in, FILE *out, const char *prompt, const char *initial, int nopts, char **opts)
{
    fprintf(out, "\033[K\033[0;1m%s\033[0m", prompt);
    size_t cap = initial ? strlen(initial) + 100 : 100;
    char *buf = memcheck(calloc(cap, 1));
    char *picked = NULL;
    int b = 0, len = 0;
    if (initial) {
        strcpy(buf, initial);
        len = (int)strlen(initial);
        b = len;
    }

    int start = 0, backtrack = 0;
    for (;;) {
        case_sensitive = 0;
        for (const char *p = buf; *p; ++p)
            case_sensitive |= ('A' <= *p && *p <= 'Z');

        int ncandidates = 0;
        picked = NULL;
        if (buf[0]) {
            for (int i = 0; i < nopts; i++) {
                int j = (start + i) % nopts;
                if (matches(opts[j], buf)) {
                    ++ncandidates;
                    if (!picked) {
                        picked = opts[j];
                        start = j;
                    }
                }
            }
        }

        if (quickpick && ncandidates == 1 && picked)
            goto finished;

        if (backtrack) fprintf(out, "\033[%dD", backtrack);
        fputs("\033[K", out);
        if (password) {
            if (picked) fputs("\033[0;32m", out);
            else if (nopts > 0) fputs("\033[0;31m", out);
            else fputs("\033[0;2m", out);
            fputc((PASSWORD)[strlen(buf) % strlen(PASSWORD)], out);
            fputs("\033[0m", out);
            backtrack = 1;
        } else {
            backtrack = draw_line(out, picked, buf, b);
        }
        fflush(out);
        int key;
      skip_redraw:
        key = bgetkey(in, NULL, NULL, -1);
        switch (key) {
            case -1: case -2: case -3: goto skip_redraw;
            case '\r':
                goto finished;
            case KEY_CTRL_C: case KEY_ESC:
              cleanup:
                free(buf);
                buf = NULL;
                picked = NULL;
                goto finished;
            case KEY_CTRL_A: case KEY_HOME:
                b = 0;
                break;
            case KEY_CTRL_E: case KEY_END:
                b = len;
                break;
            case KEY_CTRL_U: {
                int to_clear = b;
                if (to_clear) {
                    memmove(buf, buf+b, (size_t)(len-b));
                    buf[len -= b] = 0;
                    b = 0;
                }
                break;
            }
            case KEY_CTRL_K:
                if (b < len)
                    buf[len = b] = 0;
                break;
            case KEY_CTRL_N:
                if (picked) {
                    for (int i = 1; i < nopts; i++) {
                        int j = (start + i) % nopts;
                        if (matches(opts[j], buf)) {
                            start = j;
                            break;
                        }
                    }
                }
                break;
            case KEY_CTRL_P:
                if (picked) {
                    for (int i = nopts-1; i > 0; i--) {
                        int j = (start + i) % nopts;
                        if (matches(opts[j], buf)) {
                            start = j;
                            break;
                        }
                    }
                }
                break;
            case KEY_BACKSPACE: case KEY_BACKSPACE2:
                if (b > 0) {
                    --b;
                    memmove(buf+b, buf+b+1, (size_t)(len-b));
                    buf[--len] = 0;
                }
                break;
            case KEY_CTRL_D:
                if (len == 0) goto cleanup;
                // fallthrough
            case KEY_DELETE:
                if (b < len) {
                    memmove(buf+b, buf+b+1, (size_t)(len-b));
                    buf[--len] = 0;
                }
                break;
            case KEY_ARROW_LEFT: case KEY_CTRL_B:
                if (b > 0) --b;
                break;
            case KEY_ARROW_RIGHT: case KEY_CTRL_F:
                if (b < len) ++b;
                break;
            case KEY_ARROW_UP:
                if (nhistory > 0) {
                    --histindex;
                    if (histindex < 0) histindex = 0;
                    get_history(&buf, &cap, histindex);
                    len = strlen(buf);
                    b = len;
                }
                break;
            case KEY_ARROW_DOWN:
                if (nhistory > 0) {
                    ++histindex;
                    if (histindex > nhistory) histindex = nhistory;
                    get_history(&buf, &cap, histindex);
                    len = strlen(buf);
                    b = len;
                }
                break;
            case KEY_CTRL_Q: case KEY_CTRL_V: {
                int nextkey;
                while ((nextkey = bgetkey(in, NULL, NULL, -1)) < 0)
                    ;
                if (len + 1 >= (int)cap)
                    buf = memcheck(realloc(buf, (cap += 100)));
                if (b < len)
                    memmove(buf+b+1, buf+b, (size_t)(len-b+1));
                buf[b++] = (char)nextkey;
                buf[++len] = 0;
                break;
            }
            case KEY_CTRL_T: {
                if (len < 2 || b == 0) break;
                if (b < len)
                    b++;
                char tmp = buf[b-1];
                buf[b-1] = buf[b-2];
                buf[b-2] = tmp;
                break;
            }
            default:
                if ((' ' <= key && key <= '~') || key == '\t') {
                    if (len + 1 >= (int)cap)
                        buf = memcheck(realloc(buf, (cap += 100)));
                    if (b < len)
                        memmove(buf+b+1, buf+b, (size_t)(len-b+1));
                    buf[b++] = (char)key;
                    buf[++len] = 0;
                }
                break;
        }
    }
  finished:
    if (picked) picked = memcheck(strdup(picked));
    else picked = buf;
    if (backtrack || prompt[0])
        fprintf(out, "\033[%dD", backtrack + (int)strlen(prompt));
    fputs("\033[0m\033[K", out);
    if (picked != buf && buf) free(buf);
    return picked;
}

static int cmp_len(const void *v1, const void *v2)
{
    char *s1 = *(char**)v1, *s2 = *(char**)v2;
    size_t len1 = strlen(s1), len2 = strlen(s2);
    if (len1 != len2) return (int)(len1 - len2);
    return strcmp(s1, s2);
}

int main(int argc, char *argv[])
{
    int yes = 0, no = 0;
    char *prompt = NULL, *query = NULL, *histname = NULL;
    char **opts = NULL;
    char delim = '\n';
    size_t linescap = 0, linecap = 0;
    int a;
    for (a = 1; a < argc; a++) {
        if (strcmp(argv[a], "-H") == 0) {
            histname = argv[++a];
            continue;
        } else if (argv[a][0] == '-' && argv[a][1] != '-') {
            for (char *p = &argv[a][1]; *p; p++) {
                switch (*p) {
                    case 'P': password = 1; break;
                    case 'Q': quickpick = 1; break;
                    case 'h': goto help_flag;
                    case 'v': goto version_flag;
                    case '0': delim = '\0'; break;
                    case 'y': yes = 1; quickpick = 1; break;
                    case 'n': no = 1; quickpick = 1; break;
                    case 'p':
                        if (a + 1 >= argc) goto help_flag;
                        prompt = argv[++a];
                        break;
                    case 'q':
                        if (a + 1 >= argc) goto help_flag;
                        query = argv[++a];
                        break;
                    default: goto help_flag;
                }
            }
        } else if (strncmp(argv[a], "--read0", strlen("--read0")) == 0) {
            delim = '\0';
        } else if (strncmp(argv[a], "--prompt=", strlen("--prompt=")) == 0) {
            prompt = &argv[a][strlen("--prompt=")];
        } else if (strncmp(argv[a], "--query=", strlen("--query=")) == 0) {
            query = &argv[a][strlen("--query=")];
        } else if (strcmp(argv[a], "--password") == 0) {
            password = 1;
        } else if (strncmp(argv[a], "--history=", strlen("--history=")) == 0) {
            histname = &argv[a][strlen("--history=")];
        } else if (strcmp(argv[a], "--quickpick") == 0) {
            quickpick = 1;
        } else if (strcmp(argv[a], "--yes") == 0) {
            yes = 1;
            quickpick = 1;
        } else if (strcmp(argv[a], "--no") == 0) {
            no = 1;
            quickpick = 1;
        } else if (strcmp(argv[a], "--help") == 0) {
          help_flag:
            printf("ask - A simple command line input tool.\n"
                   "Usage: ask [-Q|--quickpick] [-P|--password] [-v|--version] [-h|--help] "
                   "[-y|--yes] [-n|--no] [-q |--query=query] [[-p |--prompt=]prompt [options...]]\n");
            return 0;
        } else if (strcmp(argv[a], "--version") == 0) {
          version_flag:
            printf("ask %s\n", ASK_VERSION);
            return 0;
        } else break;
    }
    if (!prompt && a < argc) prompt = argv[a++];

    int nopts = 0;
    char *line = NULL;
    struct pollfd pfd = {STDIN_FILENO, POLLIN, 0};
    if (poll(&pfd, 1, 50) > 0) {
        while ((getdelim(&line, &linecap, delim, stdin)) >= 0) {
            if ((size_t)nopts >= linescap)
                opts = memcheck(realloc(opts, (linescap += 100)*sizeof(char*)));
            if (!line[0]) continue;
            if (line[strlen(line)-1] == '\n')
                line[strlen(line)-1] = '\0';
            opts[nopts++] = memcheck(strdup(line));
        }
    }

    while (a < argc) {
        if ((size_t)nopts >= linescap)
            opts = memcheck(realloc(opts, (linescap += 100)*sizeof(char*)));
        opts[nopts++] = argv[a++];
    }

    if (yes || no) {
        if ((size_t)nopts + 4 >= linescap)
            opts = memcheck(realloc(opts, (linescap += 4)*sizeof(char*)));
        opts[nopts++] = "Y";
        opts[nopts++] = "N";
    }

    if (yes) {
        if (prompt) {
            char *p2 = memcheck(calloc(strlen(prompt)+5+1, sizeof(char)));
            sprintf(p2, "%s [Y/n]", prompt);
            prompt = p2;
        } else {
            prompt = "[Y/n]";
        }
    } else if (no) {
        if (prompt) {
            char *p2 = memcheck(calloc(strlen(prompt)+5+1, sizeof(char)));
            sprintf(p2, "%s [y/N]", prompt);
            prompt = p2;
        } else {
            prompt = "[y/N]";
        }
    }

    if (!prompt) prompt = "> ";

    FILE *tty_in = fopen("/dev/tty", "r");
    FILE *tty_out = fopen("/dev/tty", "w");
    struct termios orig_termios, bb_termios;
    tcgetattr(fileno(tty_out), &orig_termios);
    cfmakeraw(&bb_termios);
    if (tcsetattr(fileno(tty_out), TCSAFLUSH, &bb_termios) == -1)
        return 1;

    if (histname) {
        char *xdg_data = getenv("XDG_DATA_HOME");
        if (xdg_data == NULL) {
            strcpy(histpath, getenv("HOME"));
            strcat(histpath, "/.local/share/ask/");
        } else {
            strcpy(histpath, xdg_data);
            strcat(histpath, "/ask/");
        }
        strcat(histpath, histname);

        for (char* p = strchr(histpath + 1, '/'); p; p = strchr(p + 1, '/')) {
            *p = '\0';
            if (mkdir(histpath, 0777) == -1) {
                if (errno != EEXIST) {
                    *p = '/';
                    printf("Error: could not create history directory at %s", histpath);
                    return 1;
                }
            }
            *p = '/';
        }

        char buf[1024];
        FILE *f = fopen(histpath, "r");
        if (f) {
            while (fgets(buf, sizeof(buf), f) != NULL) {
                size_t len = strlen(buf);
                if (len > 1 && buf[len-1] == '\n')
                    ++nhistory;
            }
            histindex = nhistory;
        }
    }

    // Prefer shorter matches, but otherwise use alphabetic order
    qsort(opts, (size_t)nopts, sizeof(char*), cmp_len);
    char *output = get_input(tty_in, tty_out, prompt, query, nopts, opts);

    fflush(tty_out);
    tcsetattr(fileno(tty_out), TCSAFLUSH, &orig_termios);
    fclose(tty_out);
    tty_out = NULL;
    fclose(tty_in);
    tty_in = NULL;

    // This doesn't free memory, but it doesn't need to because
    // the program is exiting
    if (!output) return 1;

    if (yes)
        return strcmp(output, "N") == 0;
    if (no)
        return strcmp(output, "Y") != 0;

    fputs(output, stdout);

    if (histpath[0] && strlen(output) > 0) {
        FILE *f = fopen(histpath, "a");
        fprintf(f, "%s\n", output);
        fclose(f);
        if (++nhistory > MAX_HISTORY) {
            if (fork() == 0) {
                char *args[] = {"sed", "-i", "1d", histpath, NULL};
                execvp("sed", args);
            }
        }
    }
    return 0;
}
// vim: ts=4 sw=0 et cino=L2,l1,(0,W4,m1