]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
firstboot: add auto-completion to various fields
authorLennart Poettering <lennart@poettering.net>
Wed, 5 Feb 2025 09:55:48 +0000 (10:55 +0100)
committerLennart Poettering <lennart@poettering.net>
Mon, 17 Feb 2025 14:21:18 +0000 (15:21 +0100)
This adds TAB-based auto-completion to various fields we query from the
user, such as locale, keymap, timezone, group membership.

It makes it a lot easier to quickly iterate through firstboot without
typing too much.

src/basic/terminal-util.c
src/basic/terminal-util.h
src/firstboot/firstboot.c
src/home/homectl.c

index 53437e690f184f2e2e5a89244238e24abe666ee8..f9e2a280246fe537b714ee72f19b694a1ce2e623 100644 (file)
@@ -26,6 +26,7 @@
 #include "constants.h"
 #include "devnum-util.h"
 #include "env-util.h"
+#include "errno-list.h"
 #include "fd-util.h"
 #include "fileio.h"
 #include "fs-util.h"
@@ -231,31 +232,246 @@ int ask_char(char *ret, const char *replies, const char *fmt, ...) {
         }
 }
 
-int ask_string(char **ret, const char *text, ...) {
-        _cleanup_free_ char *line = NULL;
+typedef enum CompletionResult{
+        COMPLETION_ALREADY,       /* the input string is already complete */
+        COMPLETION_FULL,          /* completed the input string to be complete now */
+        COMPLETION_PARTIAL,       /* completed the input string so that is still incomplete */
+        COMPLETION_NONE,          /* found no matching completion */
+        _COMPLETION_RESULT_MAX,
+        _COMPLETION_RESULT_INVALID = -EINVAL,
+        _COMPLETION_RESULT_ERRNO_MAX = -ERRNO_MAX,
+} CompletionResult;
+
+static CompletionResult pick_completion(const char *string, char *const*completions, char **ret) {
+        _cleanup_free_ char *found = NULL;
+        bool partial = false;
+
+        string = strempty(string);
+
+        STRV_FOREACH(c, completions) {
+
+                /* Ignore entries that are not actually completions */
+                if (!startswith(*c, string))
+                        continue;
+
+                /* Store first completion that matches */
+                if (!found) {
+                        found = strdup(*c);
+                        if (!found)
+                                return -ENOMEM;
+
+                        continue;
+                }
+
+                /* If there's another completion that works truncate the one we already found by common
+                 * prefix */
+                size_t n = str_common_prefix(found, *c);
+                if (n == SIZE_MAX)
+                        continue;
+
+                found[n] = 0;
+                partial = true;
+        }
+
+        *ret = TAKE_PTR(found);
+
+        if (!*ret)
+                return COMPLETION_NONE;
+        if (partial)
+                return COMPLETION_PARTIAL;
+
+        return streq(string, *ret) ? COMPLETION_ALREADY : COMPLETION_FULL;
+}
+
+static void clear_by_backspace(size_t n) {
+        /* Erase the specified number of character cells backwards on the terminal */
+        for (size_t i = 0; i < n; i++)
+                fputs("\b \b", stdout);
+}
+
+int ask_string_full(
+                char **ret,
+                GetCompletionsCallback get_completions,
+                void *userdata,
+                const char *text, ...) {
+
         va_list ap;
         int r;
 
         assert(ret);
         assert(text);
 
+        /* Output the prompt */
         fputs(ansi_highlight(), stdout);
-
         va_start(ap, text);
         vprintf(text, ap);
         va_end(ap);
-
         fputs(ansi_normal(), stdout);
-
         fflush(stdout);
 
-        r = read_line(stdin, LONG_LINE_MAX, &line);
+        _cleanup_free_ char *string = NULL;
+        size_t n = 0;
+
+        /* Do interactive logic only if stdin + stdout are connected to the same place. And yes, we could use
+         * STDIN_FILENO and STDOUT_FILENO here, but let's be overly correct for once, after all libc allows
+         * swapping out stdin/stdout. */
+        int fd_input = fileno(stdin);
+        int fd_output = fileno(stdout);
+        if (fd_input < 0 || fd_output < 0 || same_fd(fd_input, fd_output) <= 0)
+                goto fallback;
+
+        /* Try to disable echo, which also tells us if this even is a terminal */
+        struct termios old_termios;
+        if (tcgetattr(fd_input, &old_termios) < 0)
+                goto fallback;
+
+        struct termios new_termios = old_termios;
+        termios_disable_echo(&new_termios);
+        if (tcsetattr(fd_input, TCSADRAIN, &new_termios) < 0)
+                return -errno;
+
+        for (;;) {
+                int c = fgetc(stdin);
+
+                /* On EOF or NUL, end the request, don't output anything anymore */
+                if (IN_SET(c, EOF, 0))
+                        break;
+
+                /* On Return also end the request, but make this visible */
+                if (IN_SET(c, '\n', '\r')) {
+                        fputc('\n', stdout);
+                        break;
+                }
+
+                if (c == '\t') {
+                        /* Tab */
+
+                        _cleanup_strv_free_ char **completions = NULL;
+                        if (get_completions) {
+                                r = get_completions(string, &completions, userdata);
+                                if (r < 0)
+                                        goto fail;
+                        }
+
+                        _cleanup_free_ char *new_string = NULL;
+                        CompletionResult cr = pick_completion(string, completions, &new_string);
+                        if (cr < 0) {
+                                r = cr;
+                                goto fail;
+                        }
+                        if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_FULL)) {
+                                /* Output the new suffix we learned */
+                                fputs(ASSERT_PTR(startswith(new_string, strempty(string))), stdout);
+
+                                /* And update the whole string */
+                                free_and_replace(string, new_string);
+                                n = strlen(string);
+                        }
+                        if (cr == COMPLETION_NONE)
+                                fputc('\a', stdout); /* BEL */
+
+                        if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_ALREADY)) {
+                                /* If this worked only partially, or if the user hit TAB even though we were
+                                 * complete already, then show the remaining options (in the latter case just
+                                 * the one). */
+                                fputc('\n', stdout);
+
+                                _cleanup_strv_free_ char **filtered = strv_filter_prefix(completions, string);
+                                if (!filtered) {
+                                        r = -ENOMEM;
+                                        goto fail;
+                                }
+
+                                r = show_menu(filtered,
+                                              /* n_columns= */ SIZE_MAX,
+                                              /* column_width= */ SIZE_MAX,
+                                              /* ellipsize_percentage= */ 0,
+                                              /* grey_prefix=*/ string,
+                                              /* with_numbers= */ false);
+                                if (r < 0)
+                                        goto fail;
+
+                                /* Show the prompt again */
+                                fputs(ansi_highlight(), stdout);
+                                va_start(ap, text);
+                                vprintf(text, ap);
+                                va_end(ap);
+                                fputs(ansi_normal(), stdout);
+                                fputs(string, stdout);
+                        }
+
+                } else if (IN_SET(c, '\b', 127)) {
+                        /* Backspace */
+
+                        if (n == 0)
+                                fputc('\a', stdout); /* BEL */
+                        else {
+                                size_t m = utf8_last_length(string, n);
+
+                                char *e = string + n - m;
+                                clear_by_backspace(utf8_console_width(e));
+
+                                *e = 0;
+                                n -= m;
+                        }
+
+                } else if (c == 21) {
+                        /* Ctrl-u → erase all input */
+
+                        clear_by_backspace(utf8_console_width(string));
+                        string[n = 0] = 0;
+
+                } else if (c == 4) {
+                        /* Ctrl-d → cancel this field input */
+
+                        r = -ECANCELED;
+                        goto fail;
+
+                } else if (char_is_cc(c) || n >= LINE_MAX)
+                        /* refuse control characters and too long strings */
+                        fputc('\a', stdout); /* BEL */
+                else {
+                        /* Regular char */
+
+                        if (!GREEDY_REALLOC(string, n+2)) {
+                                r = -ENOMEM;
+                                goto fail;
+                        }
+
+                        string[n++] = (char) c;
+                        string[n] = 0;
+
+                        fputc(c, stdout);
+                }
+
+                fflush(stdout);
+        }
+
+        if (tcsetattr(fd_input, TCSADRAIN, &old_termios) < 0)
+                return -errno;
+
+        if (!string) {
+                string = strdup("");
+                if (!string)
+                        return -ENOMEM;
+        }
+
+        *ret = TAKE_PTR(string);
+        return 0;
+
+fail:
+        (void) tcsetattr(fd_input, TCSADRAIN, &old_termios);
+        return r;
+
+fallback:
+        /* A simple fallback without TTY magic */
+        r = read_line(stdin, LONG_LINE_MAX, &string);
         if (r < 0)
                 return r;
         if (r == 0)
                 return -EIO;
 
-        *ret = TAKE_PTR(line);
+        *ret = TAKE_PTR(string);
         return 0;
 }
 
index c4ee1b32434905596c767cd57ab4fde250b061b7..698838e63f9fa1ff0f218d9bb72d05729b83271b 100644 (file)
@@ -82,7 +82,11 @@ int chvt(int vt);
 
 int read_one_char(FILE *f, char *ret, usec_t timeout, bool echo, bool *need_nl);
 int ask_char(char *ret, const char *replies, const char *text, ...) _printf_(3, 4);
-int ask_string(char **ret, const char *text, ...) _printf_(2, 3);
+
+typedef int (*GetCompletionsCallback)(const char *key, char ***ret_list, void *userdata);
+int ask_string_full(char **ret, GetCompletionsCallback cb, void *userdata, const char *text, ...) _printf_(4, 5);
+#define ask_string(ret, text, ...) ask_string_full(ret, NULL, NULL, text, ##__VA_ARGS__)
+
 bool any_key_to_proceed(void);
 int show_menu(char **x, size_t n_columns, size_t column_width, unsigned ellipsize_percentage, const char *grey_prefix, bool with_numbers);
 
index 2ee231e2d00b4c4aaeae331e2f1f3c7b8857e8fa..99e75b34224de12dbdd9f19dd9d5c168cbe14ff7 100644 (file)
@@ -135,6 +135,30 @@ static void print_welcome(int rfd) {
         done = true;
 }
 
+static int get_completions(
+                const char *key,
+                char ***ret_list,
+                void *userdata) {
+
+        int r;
+
+        if (!userdata) {
+                *ret_list = NULL;
+                return 0;
+        }
+
+        _cleanup_strv_free_ char **copy = strv_copy(userdata);
+        if (!copy)
+                return -ENOMEM;
+
+        r = strv_extend(&copy, "list");
+        if (r < 0)
+                return r;
+
+        *ret_list = TAKE_PTR(copy);
+        return 0;
+}
+
 static int prompt_loop(
                 int rfd,
                 const char *text,
@@ -142,6 +166,7 @@ static int prompt_loop(
                 unsigned ellipsize_percentage,
                 bool (*is_valid)(int rfd, const char *name),
                 char **ret) {
+
         int r;
 
         assert(text);
@@ -151,9 +176,13 @@ static int prompt_loop(
         for (;;) {
                 _cleanup_free_ char *p = NULL;
 
-                r = ask_string(&p, strv_isempty(l) ? "%s %s (empty to skip): "
-                                                   : "%s %s (empty to skip, \"list\" to list options): ",
-                               special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text);
+                r = ask_string_full(
+                                &p,
+                                get_completions,
+                                l,
+                                strv_isempty(l) ? "%s %s (empty to skip): "
+                                                : "%s %s (empty to skip, \"list\" to list options): ",
+                                special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text);
                 if (r < 0)
                         return log_error_errno(r, "Failed to query user: %m");
 
index e1611b7bfb4a1b1295fa9faa0cadb06a79eae71e..04457ebedae02b82fe4c57662a91cc0770cf91fc 100644 (file)
@@ -2476,6 +2476,28 @@ static int acquire_group_list(char ***ret) {
         return !!*ret;
 }
 
+static int group_completion_callback(const char *key, char ***ret_list, void *userdata) {
+        char ***available = userdata;
+        int r;
+
+        if (!*available) {
+                r = acquire_group_list(available);
+                if (r < 0)
+                        log_debug_errno(r, "Failed to enumerate available groups, ignoring: %m");
+        }
+
+        _cleanup_strv_free_ char **l = strv_copy(*available);
+        if (!l)
+                return -ENOMEM;
+
+        r = strv_extend(&l, "list");
+        if (r < 0)
+                return r;
+
+        *ret_list = TAKE_PTR(l);
+        return 0;
+}
+
 static int create_interactively(void) {
         _cleanup_free_ char *username = NULL;
         int r;
@@ -2531,7 +2553,8 @@ static int create_interactively(void) {
         for (;;) {
                 _cleanup_free_ char *s = NULL;
 
-                r = ask_string(&s,
+                r = ask_string_full(&s,
+                               group_completion_callback, &available,
                                "%s Please enter an auxiliary group for user %s (empty to continue, \"list\" to list available groups): ",
                                special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), username);
                 if (r < 0)