We write auto-completion for your CLI projects

Greeting



Hello! I want to share my experience of writing a cross-platform project in C ++ to integrate autocompletion into the CLI application, sit back.







Statement of the assignment



  • The application must work on Linux, macOS, Windows
  • You need to be able to set rules for autocompletion
  • Provide for typos
  • Provide a change of prompts with keyboard arrows


Preparation



I will warn you right away, we will use C++17



. , , .



#if defined(_WIN32) || defined(_WIN64)
    #define OS_WINDOWS
#elif defined(__APPLE__) || defined(__unix__) || defined(__unix)
    #define OS_POSIX
#else
    #error unsupported platform
#endif


:



#if defined(OS_WINDOWS)
    #define ENTER 13
    #define BACKSPACE 8
    #define CTRL_C 3
    #define LEFT 75
    #define RIGHT 77
    #define DEL 83
    #define UP 72
    #define DOWN 80
    #define SPACE 32
#elif defined(OS_POSIX)
    #define ENTER 10
    #define BACKSPACE 127
    #define SPACE 32
    #define LEFT 68
    #define RIGHT 67
    #define UP 65
    #define DOWN 66
    #define DEL 51
#endif
    #define TAB 9


CLI , Linux macOS API, define OS_POSIX. Windows, , , define OS_WINDOWS.



, . Redis CLI, . .



, :



/**
 * Sets the console color.
 *
 * @param color System code of target color.
 * @return Input parameter os.
 */
#if defined(OS_WINDOWS)
std::string set_console_color(uint16_t color) {
    SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), color);
    return "";
#elif defined(OS_POSIX)
std::string set_console_color(std::string color) {
    return "\033[" + color + "m";
#endif
}


- API , , << .



, , API Posix Windows, , stackoverflow:





- , "" .



/**
 * Get count of terminal cols.
 *
 * @return Width of terminal.
 */
#if defined(OS_WINDOWS)
size_t console_width() {
    CONSOLE_SCREEN_BUFFER_INFO info;
    GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info);

    short width = --info.dwSize.X;
    return size_t((width < 0) ? 0 : width);
}
#endif

/**
 * Clear terminal line.
 *
 * @param os Output stream.
 * @return input parameter os.
 */
std::ostream& clear_line(std::ostream& os) {
#if defined(OS_WINDOWS)
    size_t width = console_width();
    os << '\r' << std::string(width, ' ');
#elif defined(OS_POSIX)
    std::cout << "\033[2K";
#endif
    return os;
}


Posix , \033[2K, Windows , , .



, . cin , .



_getch(), Windows, — , . Posix , , .



#if defined(OS_POSIX)
/**
 * Read key without press ENTER.
 *
 * @return Code of key on keyboard.
 */
int _getch() {
    int ch;
    struct termios old_termios, new_termios;
    tcgetattr( STDIN_FILENO, &old_termios );
    new_termios = old_termios;
    new_termios.c_lflag &= ~(ICANON | ECHO );
    tcsetattr( STDIN_FILENO, TCSANOW, &new_termios );
    ch = getchar();
    tcsetattr( STDIN_FILENO, TCSANOW, &old_termios );
    return ch;
}
#endif








. , . :



git
    config
        --global
            user.name
                "[name]"
            user.email
                "[email]"
        user.name
            "[name]"
        user.email
            "[email]"
    init
        [repository name]
    clone
        [url]


. 1 . .. git config, init global. config --global, user.name user.email .. , [] ( ).



, , — , -.



typedef std::map<std::string, std::vector<std::string>> Dictionary;


.



/**
 * Parse config file to dictionary.
 *
 * @param file_path The path to the configuration file.
 * @return Tuple of dictionary with autocomplete rules, status of parsing and message.
 */
std::tuple<Dictionary, bool, std::string> 
parse_config_file(const std::string& file_path) {
    Dictionary dict;            //    

    std::map<int, std::string>  //     
    root_words_by_tabsize;      //     

    std::string line;           //   
    std::string token;          //    
    std::string root_word;      //        

    long tab_size = 0;          //    ()
    long tab_count = 0;         //    

    //   
    std::ifstream config_file(file_path);

    //    ,     
    if (!config_file.is_open()) {
        return std::make_tuple(
            dict,
            false,
            "Error! Can't open " + file_path + " file."
        );
    }

    //   
    while (std::getline(config_file, line)) {
        //     
        if (line.empty()) {
            continue;
        }

        //      ,    
        if (std::count(line.begin(), line.end(), '\t') != 0) {
            return std::make_tuple(
                dict,
                false,
                "Error! Use a sequence of spaces instead of a tab character."
            );
        }

        //      
        auto spaces = std::count(
            line.begin(),
            line.begin() + line.find_first_not_of(" "),
            ' '
        );

        //    , 
        //       
        if (spaces != 0 && tab_size == 0) {
            tab_size = spaces;
        }

        //    
        token = trim(line);

        //   
        if (tab_size != 0 && spaces % tab_size != 0) {
            return std::make_tuple(
                dict,
                false,
                "Error! Tab length error was made.\nPossibly in line: " + line
            );
        }

        //   
        tab_count = (tab_size == 0) ? 0 : (spaces / tab_size);

        //       
        root_words_by_tabsize[tab_count] = token;

        //      
        root_word = (tab_count == 0) ? "" : root_words_by_tabsize[tab_count - 1];

        //    ,    
        if (std::count(dict[root_word].begin(), dict[root_word].end(), token) == 0) {
            dict[root_word].push_back(token);
        }
    }

    //  
    config_file.close();

    //      
    return std::make_tuple(
        dict,
        true,
        "Success. The rule dictionary has been created."
    );
}


.



  1. , .
  2. \t ? .
  3. trim, ? .


/**
 * Remove extra spaces to the left and right of the string.
 *
 * @param str Source string.
 * @return Line without spaces on the left and right.
 */
std::string trim(std::string_view str) {
    std::string result(str);
    result.erase(0, result.find_first_not_of(" \n\r\t"));
    result.erase(result.find_last_not_of(" \n\r\t") + 1);
    return result;
}






. , ? .

, - . ? .



.



/**
 * Get the position of the beginning of the last word.
 *
 * @param str String with words.
 * @return Position of the beginning of the last word.
 */
size_t get_last_word_pos(std::string_view str) {
    //  0      
    if (std::count(str.begin(), str.end(), ' ') == str.length()) {
        return 0;
    }

    //    
    auto last_word_pos = str.rfind(' ');

    //  0,    ,    + 1
    return (last_word_pos == std::string::npos) ? 0 : last_word_pos + 1;
}

/**
 * Get the last word in string.
 *
 * @param str String with words.
 * @return Pair Position of the beginning of the
 *         last word and the last word in string.
 */
std::pair<size_t, std::string> get_last_word(std::string_view str) {
    //  
    size_t last_word_pos = get_last_word_pos(str);

    //     
    auto last_word = str.substr(last_word_pos);

    //          ( )
    return std::make_pair(last_word_pos, last_word.data());
}


, , , , , , .



.



//   std::min -  
//  MSVC 
/**
 * Get the minimum of two numbers.
 *
 * @param a First value.
 * @param b Second value.
 * @return Minimum of two numbers.
 */
size_t min_of(size_t a, size_t b) {
    return (a < b) ? a : b;
}

/**
 * Get the penultimate words.
 *
 * @param str String with words.
 * @return Pair Position of the beginning of the penultimate
 *         word and the penultimate word in string.
 */
std::pair<size_t, std::string> get_penult_word(std::string_view str) {
    //    
    size_t end_pos = min_of(str.find_last_not_of(' ') + 2, str.length());

    //     
    size_t last_word = get_last_word_pos(str.substr(0, end_pos));

    size_t penult_word_pos = 0;
    std::string penult_word = "";

    //      
    //    
    if (last_word != 0) {
        //    
        penult_word_pos = str.find_last_of(' ', last_word - 2);

        //       
        if (penult_word_pos != std::string::npos) {
            penult_word = str.substr(penult_word_pos, last_word - penult_word_pos - 1);
        }
        //    - ,     
        else {
            penult_word = str.substr(0, last_word - 1);
        }
    }

    //  
    penult_word = trim(penult_word);

    //       ( )
    return std::make_pair(penult_word_pos, penult_word);
}








? , , .



/**
 * Find strings in vector starts with substring.
 *
 * @param substr String with which the word should begin.
 * @param penult_word Penultimate word in user-entered line.
 * @param dict Vector of words.
 * @param optional_brackets String with symbols for optional values.
 * @return Vector with words starts with substring.
 */
std::vector<std::string>
words_starts_with(std::string_view substr, std::string_view penult_word,
                  Dictionary& dict, std::string_view optional_brackets) {
    std::vector<std::string> result;

    //      penult_word 
    // substr      
    if (!dict.count(penult_word.data()) ||
        substr.find_first_of(optional_brackets) != std::string::npos) 
    {
        return result;
    }

    //   ,    
    //  last_word,  substr 
    if (substr.empty()) {
        return dict[penult_word.data()];
    }

    //  ,   substr
    std::vector<std::string> candidates_list = dict[penult_word.data()];
    for (size_t i = 0 ; i < candidates_list.size(); i++) {
        if (candidates_list[i].find(substr) == 0) {
            result.push_back(dict[penult_word.data()][i]);
        }
    }

    return result;
}


? ? .



/**
 * Find strings in vector similar to a substring (max 1 error).
 *
 * @param substr String with which the word should begin.
 * @param penult_word Penultimate word in user-entered line.
 * @param dict Vector of words.
 * @param optional_brackets String with symbols for optional values.
 * @return Vector with words similar to a substring.
 */
std::vector<std::string>
words_similar_to(std::string_view substr, std::string_view penult_word, 
                 Dictionary& dict, std::string_view optional_brackets) {
    std::vector<std::string> result;

    // ,   
    if (substr.empty()) {
        return result;
    }

    std::vector<std::string> candidates_list = dict[penult_word.data()];
    for (size_t i = 0 ; i < candidates_list.size(); i++) {
        int errors = 0;

        //  
        std::string candidate = candidates_list[i];

        //   
        for (size_t j = 0; j < substr.length(); j++) {

            // ,       
            if (optional_brackets.find_first_of(candidate[j]) != std::string::npos) {
                errors = 2;
                break;
            }

            if (substr[j] != candidate[j]) {
                errors += 1;
            }

            if (errors > 1) {
                break;
            }
        }

        //  ,    
        if (errors <= 1) {
            result.push_back(candidate);
        }
    }

    return result;
}


, .

.



/**
 * Get the word-prediction by the index.
 *
 * @param buffer String with user input.
 * @param dict Dictionary with rules.
 * @param number Index of word-prediction.
 * @param optional_brackets String with symbols for optional values.
 * @return Tuple of word-prediction, phrase for output, substring of buffer
 *         preceding before phrase, start position of last word.
 */
std::tuple<std::string, std::string, std::string, size_t>
get_prediction(std::string_view buffer, Dictionary& dict, size_t number,
               std::string_view optional_brackets) {
    //     
    auto [last_word_pos, last_word] = get_last_word(buffer);

    //     
    auto [_, penult_word] = get_penult_word(buffer);

    std::string prediction; // 
    std::string phrase;     //   
    std::string prefix;     //  ,  

    //  
    std::vector<std::string> starts_with = words_starts_with(
        last_word, penult_word, dict, optional_brackets
    );

    //  ,    
    if (!starts_with.empty()) {
        prediction = starts_with[number % starts_with.size()];
        phrase = prediction;
        prefix = buffer.substr(0, last_word_pos);
    }
    //     
    else {
        //     
        std::vector<std::string> similar = words_similar_to(
            last_word, penult_word, dict, optional_brackets
        );

        //  ,    
        if (!similar.empty()) {
            prediction = similar[number % similar.size()];
            phrase = " maybe you mean " + prediction + "?";
            prefix = buffer;
        }
    }

    //   
    return std::make_tuple(prediction, phrase, prefix, last_word_pos);
}








. .



/**
 * Gets current terminal cursor position.
 *
 * @return Y position of terminal cursor.
 */
short cursor_y_pos() {
#if defined(OS_WINDOWS)
    CONSOLE_SCREEN_BUFFER_INFO info;
    GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info);
    return info.dwCursorPosition.Y;
#elif defined(OS_POSIX)
    struct termios term, restore;
    char ch, buf[30] = {0};
    short i = 0, pow = 1, y = 0;

    tcgetattr(0, &term);
    tcgetattr(0, &restore);
    term.c_lflag &= ~(ICANON|ECHO);
    tcsetattr(0, TCSANOW, &term);

    write(1, "\033[6n", 4);

    for (ch = 0; ch != 'R'; i++) {
        read(0, &ch, 1);
        buf[i] = ch;
    }

    i -= 2;
    while (buf[i] != ';') {
        i -= 1;
    }

    i -= 1;
    while (buf[i] != '[') {
        y = y + ( buf[i] - '0' ) * pow;
        pow *= 10;
        i -= 1;
    }

    tcsetattr(0, TCSANOW, &restore);
    return y;
#endif
}

/**
 * Move terminal cursor at position x and y.
 *
 * @param x X position to move.
 * @param x Y position to move.
 * @return Void.
 */
void goto_xy(short x, short y) {
#if defined(OS_WINDOWS)
    COORD xy {--x, y};
    SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), xy);
#elif defined(OS_POSIX)
    printf("\033[%d;%dH", y, x);
#endif
}

/**
 * Printing user input with prompts.
 *
 * @param buffer String - User input.
 * @param dict Vector of words.
 * @param line_title Line title of CLI when entering command.
 * @param number Hint number.
 * @param optional_brackets String with symbols for optional values.
 * @param title_color System code of title color     (line title color).
 * @param predict_color System code of predict color (prediction color).
 * @param default_color System code of default color (user input color).
 * @return Void.
 */
#if defined(OS_WINDOWS)
void print_with_prompts(std::string_view buffer, Dictionary& dict,
                        std::string_view line_title, size_t number,
                        std::string_view optional_brackets,
                        uint16_t title_color, uint16_t predict_color,
                        uint16_t default_color) {
#else
void print_with_prompts(std::string_view buffer, Dictionary& dict,
                        std::string_view line_title, size_t number,
                        std::string_view optional_brackets,
                        std::string title_color, std::string predict_color,
                        std::string default_color) {
#endif
    //      ,  
    auto [_, phrase, prefix, __] = 
        get_prediction(buffer, dict, number, optional_brackets);

    std::string delimiter = line_title.empty() ? "" : " ";

    std::cout << clear_line;

    std::cout << '\r' << set_console_color(title_color) << line_title
              << set_console_color(default_color) << delimiter << prefix
              << set_console_color(predict_color) << phrase;

    std::cout << '\r' << set_console_color(title_color) << line_title
              << set_console_color(default_color) << delimiter << buffer;
}

/**
 * Reading user input with autocomplete.
 *
 * @param dict Vector of words.
 * @param optional_brackets String with symbols for optional values.
 * @param title_color System code of title color     (line title color).
 * @param predict_color System code of predict color (prediction color).
 * @param default_color System code of default color (user input color).
 * @return User input.
 */
#if defined(OS_WINDOWS)
std::string input(Dictionary& dict, std::string_view line_title,
                  std::string_view optional_brackets, uint16_t title_color,
                  uint16_t predict_color, uint16_t default_color) {
#else
std::string input(Dictionary& dict, std::string_view line_title,
                  std::string_view optional_brackets, std::string title_color,
                  std::string predict_color, std::string default_color) {
#endif
    std::string buffer;       // 
    size_t offset = 0;        //     
    size_t number = 0;        //  () ,  
    short y = cursor_y_pos(); //      Y  

    //  
    #if defined(OS_WINDOWS)
    std::vector<int> ignore_keys({1, 2, 19, 24, 26});
    #elif defined(OS_POSIX)
    std::vector<int> ignore_keys({1, 2, 4, 24});
    #endif

    while (true) {
        //     
        print_with_prompts(buffer, dict, line_title, number, optional_brackets,
                           title_color, predict_color, default_color);

        //     
        short x = short(
            buffer.length() + line_title.length() + !line_title.empty() + 1 - offset
        );
        goto_xy(x, y);

        //   
        int ch = _getch();

        //  ,   Enter
        if (ch == ENTER) {
            return buffer;
        }

        //    CLI  Windows
        #if defined(OS_WINDOWS)
        else if (ch == CTRL_C) {
            exit(0);
        }
        #endif

        //     BACKSPACE
        else if (ch == BACKSPACE) {
            if (!buffer.empty() && buffer.length() - offset >= 1) {
                buffer.erase(buffer.length() - offset - 1, 1);
            }
        }

        //     TAB
        else if (ch == TAB) {
            //   
            auto [prediction, _, __, last_word_pos] = 
                get_prediction(buffer, dict, number, optional_brackets);

            //  ,  
            if (!prediction.empty() && 
                prediction.find_first_of(optional_brackets) == std::string::npos) {
                buffer = buffer.substr(0, last_word_pos) + prediction + " ";
            }

            //     
            offset = 0;
            number = 0;
        }

        //  
        #if defined(OS_WINDOWS)
        else if (ch == 0 || ch == 224)
        #elif defined(OS_POSIX)
        else if (ch == 27 && _getch() == 91)
        #endif
                switch (_getch()) {
                    case LEFT:
                        //  ,    
                        offset = (offset < buffer.length()) 
                                    ? offset + 1
                                    : buffer.length();
                        break;
                    case RIGHT:
                        //  ,    
                        offset = (offset > 0) ? offset - 1 : 0;
                        break;
                    case UP:
                        //   
                        number = number + 1;
                        std::cout << clear_line;
                        break;
                    case DOWN:
                        //   
                        number = number - 1;
                        std::cout << clear_line;
                        break;
                    case DEL:
                    //  ,   DELETE
                    #if defined(OS_POSIX)
                    if (_getch() == 126)
                    #endif
                    {
                        if (!buffer.empty() && offset != 0) {
                            buffer.erase(buffer.length() - offset, 1);
                            offset -= 1;
                        }
                    }
                    default:
                        break;
                }

        //       
        //     
        else if (!std::count(ignore_keys.begin(), ignore_keys.end(), ch)) {
            buffer.insert(buffer.end() - offset, (char)ch);

            if (ch == SPACE) {
                number = 0;
            }
        }
    }
}


, . .





#include <iostream>
#include <string>

#include "../include/autocomplete.h"

int main() {
    //   
    std::string config_file_path = "../config.txt";

    // ,     
    //  ( )
    std::string optional_brackets = "[";

    //   
    #if defined(OS_WINDOWS)
        uint16_t title_color = 160; // by default 10
        uint16_t predict_color = 8; // by default 8
        uint16_t default_color = 7; // by default 7
    #elif defined(OS_POSIX)
        // Set the value that goes between \033 and m ( \033{your_value}m )
        std::string title_color = "0;30;102";  // by default 92
        std::string predict_color = "90";      // by default 90
        std::string default_color = "0";       // by default 90
    #endif

    //    
    size_t command_counter = 0;

    //  
    auto [dict, status, message] = parse_config_file(config_file_path);

    //    
    if (status) {
        std::cerr << "Attention! Please run the executable file only" << std::endl
                  << "through the command line!\n\n";

        std::cerr << "- To switch the prompts press UP or DOWN arrow." << std::endl;
        std::cerr << "- To move cursor press LEFT or RIGHT arrow." << std::endl;
        std::cerr << "- To edit input press DELETE or BACKSPACE key." << std::endl;
        std::cerr << "- To apply current prompt press TAB key.\n\n";

        //  
        while (true) {
            //   
            std::string line_title = "git [" + std::to_string(command_counter) + "]:";

            //      
            std::string command = input(dict, line_title, optional_brackets,
                                        title_color, predict_color, default_color);

            //  -   
            std::cout << std::endl << command << std::endl << std::endl;

            command_counter++;
        }
    }
    //  ,      
    else {
        std::cerr << message << std::endl;
    }

    return 0;
}






macOS, Linux, Windows. .



:



As you can see, it is not easy to write cross-platform code (in our case, we had to write what is on Windows out of the box for Linux manually and vice versa), but it is very interesting and the very fact that it all works on all three OS is extremely ...



I hope I was helpful to someone. If you have something to add, I will listen carefully in the comments.



The source code can be found here .

Use it to your health.




All Articles