Русский
Русский
English
Статистика
Реклама

Из песочницы Пишем автодополнение для ваших CLI проектов

Приветствие


Всем привет! Хочу поделиться своим опытом написания кроссплатформенного проекта на C++ для интеграции автодополнения в CLI приложения, усаживайтесь поудобнее.




Формулировка задания


  • Приложение должно работать на Linux, macOS, Windows
  • Необходима возможность задавать правила для автодополнения
  • Предусмотреть наличие опечаток
  • Предусмотреть смену подсказок стрелками клавиатуры

Приготовления


Сразу предупрежу, использовать будем 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) {#elsevoid 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) {#elsestd::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. Все работает отлично.


Заключение:


Как вы видите, писать кроссплатформенный код довольно не просто (в нашем случае пришлось писать, то что есть на Windows из коробки для Linux вручную и наоборот), однако это очень интересно и сам факт, что это все работает на всех трех ОС, крайне доставляет.


Надеюсь, я был кому-нибудь полезен. Если вам есть что дополнить, буду внимательно слушать в комментариях.


Исходный код можно взять тут.
Пользуйтесь на здоровье.

Источник: habr.com
К списку статей
Опубликовано: 20.08.2020 14:16:33
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

C++

Разработка под linux

Разработка под macos

Разработка под windows

Crossplatform

Autocomplete

Terminal

Cli

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru