diff --git a/.gitignore b/.gitignore index 96b5d27..8579b02 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ tests/unit/test_chat_room tests/unit/test_history_view tests/unit/test_i18n tests/unit/test_system_message +tests/unit/test_command_catalog tests/unit/test_help_text tests/unit/test_manual_text tests/unit/test_support_text diff --git a/README.md b/README.md index 6831266..6c7e57b 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ TNT/ ├── src/ # source code │ ├── main.c # entry point │ ├── cli_text.c # startup CLI help and option text +│ ├── command_catalog.c # command metadata │ ├── ssh_server.c # SSH server implementation │ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── chat_room.c # chat room logic diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4e6f95e..1002eac 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Changed +- Command names, aliases, help summaries, concise-manual command rows, and + unknown-command suggestions now share a dedicated `command_catalog` module. - Collapsed the interactive help surface around a concise Unix-style `:help` manual and the `?` full key reference; `:support` is no longer a user-facing command. diff --git a/docs/Development-Guide.md b/docs/Development-Guide.md index 0bc47ce..1b091d5 100644 --- a/docs/Development-Guide.md +++ b/docs/Development-Guide.md @@ -73,6 +73,7 @@ src/ ├── bootstrap.c - SSH authentication/session bootstrap ├── input.c - Interactive session loop and key handling ├── commands.c - COMMAND-mode command dispatch +├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── exec.c - SSH exec command dispatch ├── chat_room.c - Chat room logic and message broadcasting ├── message.c - Message persistence (RFC3339 format) @@ -97,6 +98,7 @@ include/ ├── bootstrap.h - SSH session bootstrap interface ├── chat_room.h - Chat room interface ├── message.h - Message structure and persistence +├── command_catalog.h - COMMAND-mode command metadata interface ├── history_view.h - Scroll-state helpers ├── tui.h - TUI rendering functions ├── i18n.h - Language and shared text IDs diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index af90183..f289663 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -46,6 +46,7 @@ INSERT MODE STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text + src/command_catalog.c command metadata src/ssh_server.c SSH listener and server setup src/bootstrap.c SSH auth/session bootstrap src/chat_room.c broadcast and room state diff --git a/include/command_catalog.h b/include/command_catalog.h new file mode 100644 index 0000000..900a086 --- /dev/null +++ b/include/command_catalog.h @@ -0,0 +1,37 @@ +#ifndef COMMAND_CATALOG_H +#define COMMAND_CATALOG_H + +#include "common.h" + +typedef enum { + TNT_COMMAND_USERS, + TNT_COMMAND_HELP, + TNT_COMMAND_LANG, + TNT_COMMAND_MSG, + TNT_COMMAND_INBOX, + TNT_COMMAND_NICK, + TNT_COMMAND_LAST, + TNT_COMMAND_SEARCH, + TNT_COMMAND_MUTE_JOINS, + TNT_COMMAND_QUIT, + TNT_COMMAND_CLEAR, + TNT_COMMAND_COUNT +} tnt_command_id_t; + +typedef struct { + tnt_command_id_t id; + const char *canonical; + const char *names[4]; + bool accepts_args; +} tnt_command_spec_t; + +const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id); +bool command_catalog_match(const char *line, tnt_command_id_t *id, + const char **args); +const char *command_catalog_suggest(const char *name); +void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang); +void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang); + +#endif /* COMMAND_CATALOG_H */ diff --git a/include/help_text.h b/include/help_text.h index ae44b31..8441e31 100644 --- a/include/help_text.h +++ b/include/help_text.h @@ -3,6 +3,7 @@ #include "common.h" -const char *help_text_full(help_lang_t lang); +void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang); #endif /* HELP_TEXT_H */ diff --git a/include/manual_text.h b/include/manual_text.h index 9a798cb..425e0ee 100644 --- a/include/manual_text.h +++ b/include/manual_text.h @@ -3,6 +3,7 @@ #include "common.h" -const char *manual_text_interactive(help_lang_t lang); +void manual_text_append_interactive(char *buffer, size_t buf_size, + size_t *pos, help_lang_t lang); #endif /* MANUAL_TEXT_H */ diff --git a/src/command_catalog.c b/src/command_catalog.c new file mode 100644 index 0000000..6575c78 --- /dev/null +++ b/src/command_catalog.c @@ -0,0 +1,259 @@ +#include "command_catalog.h" + +#include + +typedef struct { + tnt_command_spec_t spec; + const char *full_usage_en; + const char *full_usage_zh; + const char *summary_en; + const char *summary_zh; + const char *manual_usage_en; + const char *manual_usage_zh; + int manual_group; +} command_catalog_entry_t; + +static const command_catalog_entry_t entries[] = { + { + {TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}, false}, + ":users, :list, :who", ":users, :list, :who", + "Show online users", "显示在线用户", + ":users", ":users", 1 + }, + { + {TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}, true}, + ":msg , :w ", + ":msg <用户> <文本>, :w <用户> <文本>", + "Whisper to user", "私聊", + ":msg ", ":msg <用户> <文本>", 2 + }, + { + {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}, false}, + ":inbox", ":inbox", + "Show whispers", "查看私聊", + ":inbox", ":inbox", 2 + }, + { + {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}, true}, + ":nick , :name ", ":nick <名字>, :name <名字>", + "Change nickname", "更改昵称", + ":nick ", ":nick <名字>", 2 + }, + { + {TNT_COMMAND_LAST, "last", {"last", NULL}, true}, + ":last [N]", ":last [N]", + "Show last N messages (max 50)", "显示最后 N 条消息(最多50)", + ":last [N]", ":last [N]", 1 + }, + { + {TNT_COMMAND_SEARCH, "search", {"search", NULL}, true}, + ":search ", ":search <关键词>", + "Search message history", "搜索消息历史", + ":search ", ":search <词>", 1 + }, + { + {TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}, false}, + ":mute-joins, :mute", ":mute-joins, :mute", + "Toggle join/leave notices", "切换加入/离开提示", + ":mute-joins", ":mute-joins", 3 + }, + { + {TNT_COMMAND_HELP, "help", {"help", NULL}, false}, + ":help", ":help", + "Show concise manual", "显示简明手册", + NULL, NULL, 0 + }, + { + {TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}, true}, + ":lang ", ":lang ", + "Switch UI language", "切换界面语言", + NULL, NULL, 0 + }, + { + {TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}, false}, + ":clear, :cls", ":clear, :cls", + "Clear command output", "清空命令输出", + ":clear", ":clear", 3 + }, + { + {TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}, false}, + ":q, :quit, :exit", ":q, :quit, :exit", + "Disconnect", "断开连接", + ":q", ":q", 3 + } +}; + +static const command_catalog_entry_t *entry_for_id(tnt_command_id_t id) { + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + if (entries[i].spec.id == id) { + return &entries[i]; + } + } + return NULL; +} + +static const char *skip_spaces(const char *value) { + while (value && *value == ' ') { + value++; + } + return value; +} + +static bool name_matches(const char *line, const char *name, + const char **args) { + size_t len; + + if (!line || !name) { + return false; + } + + len = strlen(name); + if (strncmp(line, name, len) != 0) { + return false; + } + if (line[len] != '\0' && line[len] != ' ') { + return false; + } + + if (args) { + *args = skip_spaces(line + len); + } + return true; +} + +static int min3(int a, int b, int c) { + int m = a < b ? a : b; + return m < c ? m : c; +} + +static int edit_distance(const char *a, const char *b) { + size_t la = strlen(a); + size_t lb = strlen(b); + int prev[32]; + int curr[32]; + + if (la >= 32 || lb >= 32) { + return 99; + } + + for (size_t j = 0; j <= lb; j++) { + prev[j] = (int)j; + } + + for (size_t i = 1; i <= la; i++) { + curr[0] = (int)i; + for (size_t j = 1; j <= lb; j++) { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, + prev[j - 1] + cost); + } + for (size_t j = 0; j <= lb; j++) { + prev[j] = curr[j]; + } + } + + return prev[lb]; +} + +const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id) { + const command_catalog_entry_t *entry = entry_for_id(id); + return entry ? &entry->spec : NULL; +} + +bool command_catalog_match(const char *line, tnt_command_id_t *id, + const char **args) { + line = skip_spaces(line); + if (!line || line[0] == '\0') { + return false; + } + + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + const tnt_command_spec_t *spec = &entries[i].spec; + for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) { + const char *candidate_args = NULL; + if (!spec->names[n]) { + break; + } + if (!name_matches(line, spec->names[n], &candidate_args)) { + continue; + } + if (candidate_args && candidate_args[0] != '\0' && + !spec->accepts_args) { + continue; + } + if (id) { + *id = spec->id; + } + if (args) { + *args = candidate_args ? candidate_args : ""; + } + return true; + } + } + + return false; +} + +const char *command_catalog_suggest(const char *name) { + const char *best = NULL; + int best_distance = 99; + + if (!name || !*name) { + return NULL; + } + + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + const tnt_command_spec_t *spec = &entries[i].spec; + for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) { + int distance; + if (!spec->names[n]) { + break; + } + distance = edit_distance(name, spec->names[n]); + if (distance < best_distance) { + best_distance = distance; + best = spec->canonical; + } + } + } + + return best_distance <= 2 ? best : NULL; +} + +void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang) { + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + const char *usage = lang == LANG_ZH ? entries[i].full_usage_zh + : entries[i].full_usage_en; + const char *summary = lang == LANG_ZH ? entries[i].summary_zh + : entries[i].summary_en; + buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n", + usage, summary); + } +} + +void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang) { + for (int group = 1; group <= 3; group++) { + bool first = true; + + buffer_appendf(buffer, buf_size, pos, " "); + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + const char *usage; + if (entries[i].manual_group != group) { + continue; + } + usage = lang == LANG_ZH ? entries[i].manual_usage_zh + : entries[i].manual_usage_en; + if (!usage) { + continue; + } + if (!first) { + buffer_appendf(buffer, buf_size, pos, ", "); + } + buffer_appendf(buffer, buf_size, pos, "%s", usage); + first = false; + } + buffer_appendf(buffer, buf_size, pos, "\n"); + } +} diff --git a/src/commands.c b/src/commands.c index d951130..2bbc0f3 100644 --- a/src/commands.c +++ b/src/commands.c @@ -7,6 +7,7 @@ #include "commands.h" #include "chat_room.h" #include "client.h" +#include "command_catalog.h" #include "common.h" #include "i18n.h" #include "manual.h" @@ -46,65 +47,6 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos, } } -static int min3(int a, int b, int c) { - int m = a < b ? a : b; - return m < c ? m : c; -} - -static int command_edit_distance(const char *a, const char *b) { - size_t la = strlen(a); - size_t lb = strlen(b); - int prev[32]; - int curr[32]; - - if (la >= 32 || lb >= 32) { - return 99; - } - - for (size_t j = 0; j <= lb; j++) { - prev[j] = (int)j; - } - - for (size_t i = 1; i <= la; i++) { - curr[0] = (int)i; - for (size_t j = 1; j <= lb; j++) { - int cost = a[i - 1] == b[j - 1] ? 0 : 1; - curr[j] = min3(prev[j] + 1, curr[j - 1] + 1, - prev[j - 1] + cost); - } - for (size_t j = 0; j <= lb; j++) { - prev[j] = curr[j]; - } - } - - return prev[lb]; -} - -static const char *suggest_command(const char *cmd) { - static const char *commands[] = { - "list", "users", "who", "nick", "name", "msg", "w", "inbox", - "last", "search", "mute-joins", "mute", "lang", "language", - "help", "clear", "cls", - "q", "quit", "exit" - }; - const char *best = NULL; - int best_distance = 99; - - if (!cmd || !*cmd) { - return NULL; - } - - for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { - int distance = command_edit_distance(cmd, commands[i]); - if (distance < best_distance) { - best_distance = distance; - best = commands[i]; - } - } - - return best_distance <= 2 ? best : NULL; -} - void commands_dispatch(client_t *client) { char cmd_buf[256]; strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); @@ -138,8 +80,34 @@ void commands_dispatch(client_t *client) { client->command_history_pos = client->command_history_count; } - if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 || - strcmp(cmd, "who") == 0) { + if (cmd[0] == '\0') { + /* Empty command */ + client->mode = MODE_NORMAL; + client->command_input[0] = '\0'; + tui_render_screen(client); + return; + } + + tnt_command_id_t command_id; + const char *arg = ""; + if (!command_catalog_match(cmd, &command_id, &arg)) { + const char *suggestion = command_catalog_suggest(cmd); + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->help_lang, + I18N_UNKNOWN_COMMAND_FORMAT), + cmd); + if (suggestion) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->help_lang, + I18N_DID_YOU_MEAN_FORMAT), + suggestion); + } + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE)); + goto cmd_done; + } + + if (command_id == TNT_COMMAND_USERS) { pthread_rwlock_rdlock(&g_room->lock); int total = g_room->client_count; buffer_appendf(output, sizeof(output), &pos, @@ -167,22 +135,13 @@ void commands_dispatch(client_t *client) { } pthread_rwlock_unlock(&g_room->lock); - } else if (strcmp(cmd, "help") == 0) { + } else if (command_id == TNT_COMMAND_HELP) { manual_append_interactive_panel(output, sizeof(output), &pos, client->help_lang); - } else if (strcmp(cmd, "lang") == 0 || strcmp(cmd, "language") == 0 || - strncmp(cmd, "lang ", 5) == 0 || - strncmp(cmd, "language ", 9) == 0) { - char *arg = NULL; + } else if (command_id == TNT_COMMAND_LANG) { help_lang_t next_lang; - if (strncmp(cmd, "lang ", 5) == 0) { - arg = cmd + 5; - } else if (strncmp(cmd, "language ", 9) == 0) { - arg = cmd + 9; - } - if (!arg || arg[0] == '\0') { buffer_appendf(output, sizeof(output), &pos, i18n_text(client->help_lang, @@ -201,9 +160,8 @@ void commands_dispatch(client_t *client) { arg); } - } else if (strcmp(cmd, "msg") == 0 || strcmp(cmd, "w") == 0 || - strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) { - char *rest = (cmd[0] == 'w') ? cmd + 1 : cmd + 3; + } else if (command_id == TNT_COMMAND_MSG) { + const char *rest = arg; while (*rest == ' ') rest++; char target_name[MAX_USERNAME_LEN] = {0}; int ti = 0; @@ -273,7 +231,7 @@ void commands_dispatch(client_t *client) { } } - } else if (strcmp(cmd, "inbox") == 0) { + } else if (command_id == TNT_COMMAND_INBOX) { /* Snapshot the inbox under io_lock so a concurrent sender doesn't * tear what we're rendering. Counter reset happens after copy. */ whisper_t snapshot[WHISPER_INBOX_SIZE]; @@ -304,9 +262,8 @@ void commands_dispatch(client_t *client) { ts, snapshot[i].from, snapshot[i].content); } - } else if (strcmp(cmd, "nick") == 0 || strcmp(cmd, "name") == 0 || - strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) { - char *new_name = cmd + 4; + } else if (command_id == TNT_COMMAND_NICK) { + const char *new_name = arg; while (*new_name == ' ') new_name++; if (new_name[0] == '\0') { @@ -367,8 +324,7 @@ void commands_dispatch(client_t *client) { } } - } else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) { - char *arg = cmd + 4; + } else if (command_id == TNT_COMMAND_LAST) { while (*arg == ' ') arg++; int n = 10; if (*arg != '\0') { @@ -397,8 +353,8 @@ void commands_dispatch(client_t *client) { } free(last_msgs); - } else if (strcmp(cmd, "search") == 0 || strncmp(cmd, "search ", 7) == 0) { - char *query = cmd + 6; + } else if (command_id == TNT_COMMAND_SEARCH) { + const char *query = arg; while (*query == ' ') query++; if (*query == '\0') { buffer_appendf(output, sizeof(output), &pos, "%s", @@ -427,7 +383,7 @@ void commands_dispatch(client_t *client) { free(found); } - } else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) { + } else if (command_id == TNT_COMMAND_MUTE_JOINS) { client->mute_joins = !client->mute_joins; buffer_appendf(output, sizeof(output), &pos, i18n_text(client->help_lang, I18N_MUTE_JOINS_FORMAT), @@ -436,36 +392,13 @@ void commands_dispatch(client_t *client) { I18N_MUTE_JOINS_MUTED : I18N_MUTE_JOINS_UNMUTED)); - } else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 || - strcmp(cmd, "exit") == 0) { + } else if (command_id == TNT_COMMAND_QUIT) { client->connected = false; return; - } else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) { + } else if (command_id == TNT_COMMAND_CLEAR) { buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->help_lang, I18N_CLEAR_DONE)); - - } else if (cmd[0] == '\0') { - /* Empty command */ - client->mode = MODE_NORMAL; - client->command_input[0] = '\0'; - tui_render_screen(client); - return; - - } else { - const char *suggestion = suggest_command(cmd); - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->help_lang, - I18N_UNKNOWN_COMMAND_FORMAT), - cmd); - if (suggestion) { - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->help_lang, - I18N_DID_YOU_MEAN_FORMAT), - suggestion); - } - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE)); } cmd_done: diff --git a/src/help_text.c b/src/help_text.c index 477257c..d528c01 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -1,115 +1,101 @@ #include "help_text.h" -const char *help_text_full(help_lang_t lang) { +#include "command_catalog.h" + +void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, + help_lang_t lang) { if (lang == LANG_EN) { - return "TNT KEY REFERENCE\n" - "\n" - "OPERATING MODES:\n" - " INSERT - Type and send messages (default)\n" - " NORMAL - Browse message history\n" - " COMMAND - Execute commands\n" - "\n" - "INSERT MODE KEYS:\n" - " ESC - Enter NORMAL mode\n" - " Enter - Send message\n" - " Backspace - Delete character\n" - " Ctrl+W - Delete last word\n" - " Ctrl+U - Delete line\n" - " Ctrl+C - Enter NORMAL mode\n" - "\n" - "NORMAL MODE KEYS:\n" - " Opens at latest messages\n" - " Follows latest until you scroll up\n" - " i - Return to INSERT mode\n" - " : - Enter COMMAND mode\n" - " j/k - Scroll down/up one line\n" - " Ctrl+D/U - Scroll half page down/up\n" - " Ctrl+F/B - Scroll full page down/up\n" - " PgDn/PgUp - Scroll full page down/up\n" - " End/Home - Jump to bottom/top\n" - " g/G - Jump to top/bottom\n" - " ? - Show full key reference\n" - " Ctrl+C - Exit chat\n" - "\n" - "AVAILABLE COMMANDS:\n" - " :list, :users - Show online users\n" - " :nick - Change nickname\n" - " :msg - Whisper to user\n" - " :w - Short alias for :msg\n" - " :inbox - Show whispers\n" - " :last [N] - Show last N messages (max 50)\n" - " :search - Search message history\n" - " :mute-joins - Toggle join/leave notices\n" - " :help - Show concise manual\n" - " :lang - Switch UI language\n" - " :clear - Clear command output\n" - " :q, :quit, :exit - Disconnect\n" - "\n" - "SPECIAL MESSAGES:\n" - " /me - Send action (e.g. /me waves)\n" - " @username - Mention user (bell + highlight)\n" - "\n" - "HELP SCREEN KEYS:\n" - " q, ESC - Close help\n" - " j/k - Scroll down/up\n" - " Ctrl+D/U - Scroll half page down/up\n" - " Ctrl+F/B - Scroll full page down/up\n" - " g/G - Jump to top/bottom\n" - " e/z - Switch English/Chinese\n"; + buffer_appendf(buffer, buf_size, pos, + "TNT KEY REFERENCE\n" + "\n" + "OPERATING MODES:\n" + " INSERT - Type and send messages (default)\n" + " NORMAL - Browse message history\n" + " COMMAND - Execute commands\n" + "\n" + "INSERT MODE KEYS:\n" + " ESC - Enter NORMAL mode\n" + " Enter - Send message\n" + " Backspace - Delete character\n" + " Ctrl+W - Delete last word\n" + " Ctrl+U - Delete line\n" + " Ctrl+C - Enter NORMAL mode\n" + "\n" + "NORMAL MODE KEYS:\n" + " Opens at latest messages\n" + " Follows latest until you scroll up\n" + " i - Return to INSERT mode\n" + " : - Enter COMMAND mode\n" + " j/k - Scroll down/up one line\n" + " Ctrl+D/U - Scroll half page down/up\n" + " Ctrl+F/B - Scroll full page down/up\n" + " PgDn/PgUp - Scroll full page down/up\n" + " End/Home - Jump to bottom/top\n" + " g/G - Jump to top/bottom\n" + " ? - Show full key reference\n" + " Ctrl+C - Exit chat\n" + "\n" + "AVAILABLE COMMANDS:\n"); + command_catalog_append_full(buffer, buf_size, pos, lang); + buffer_appendf(buffer, buf_size, pos, + "\n" + "SPECIAL MESSAGES:\n" + " /me - Send action (e.g. /me waves)\n" + " @username - Mention user (bell + highlight)\n" + "\n" + "HELP SCREEN KEYS:\n" + " q, ESC - Close help\n" + " j/k - Scroll down/up\n" + " Ctrl+D/U - Scroll half page down/up\n" + " Ctrl+F/B - Scroll full page down/up\n" + " g/G - Jump to top/bottom\n" + " e/z - Switch English/Chinese\n"); + return; } - return "TNT 按键参考\n" - "\n" - "操作模式:\n" - " INSERT - 输入和发送消息(默认)\n" - " NORMAL - 浏览消息历史\n" - " COMMAND - 执行命令\n" - "\n" - "INSERT 模式按键:\n" - " ESC - 进入 NORMAL 模式\n" - " Enter - 发送消息\n" - " Backspace - 删除字符\n" - " Ctrl+W - 删除上个单词\n" - " Ctrl+U - 删除整行\n" - " Ctrl+C - 进入 NORMAL 模式\n" - "\n" - "NORMAL 模式按键:\n" - " 默认停在最新消息\n" - " 未向上翻阅时自动跟随最新消息\n" - " i - 返回 INSERT 模式\n" - " : - 进入 COMMAND 模式\n" - " j/k - 向下/上滚动一行\n" - " Ctrl+D/U - 向下/上滚动半页\n" - " Ctrl+F/B - 向下/上滚动整页\n" - " PgDn/PgUp - 向下/上滚动整页\n" - " End/Home - 跳到底部/顶部\n" - " g/G - 跳到顶部/底部\n" - " ? - 显示完整按键参考\n" - " Ctrl+C - 退出聊天\n" - "\n" - "可用命令:\n" - " :list, :users - 显示在线用户\n" - " :nick <名字> - 更改昵称\n" - " :msg <用户> <文本> - 私聊\n" - " :w <用户> <文本> - :msg 的简写\n" - " :inbox - 查看私聊\n" - " :last [N] - 显示最后 N 条消息(最多50)\n" - " :search <关键词> - 搜索消息历史\n" - " :mute-joins - 切换加入/离开提示\n" - " :help - 显示简明手册\n" - " :lang - 切换界面语言\n" - " :clear - 清空命令输出\n" - " :q, :quit, :exit - 断开连接\n" - "\n" - "特殊消息:\n" - " /me <动作> - 发送动作 (如 /me 挥手)\n" - " @用户名 - 提及用户 (响铃+高亮)\n" - "\n" - "帮助界面按键:\n" - " q, ESC - 关闭帮助\n" - " j/k - 向下/上滚动\n" - " Ctrl+D/U - 向下/上滚动半页\n" - " Ctrl+F/B - 向下/上滚动整页\n" - " g/G - 跳到顶部/底部\n" - " e/z - 切换英文/中文\n"; + buffer_appendf(buffer, buf_size, pos, + "TNT 按键参考\n" + "\n" + "操作模式:\n" + " INSERT - 输入和发送消息(默认)\n" + " NORMAL - 浏览消息历史\n" + " COMMAND - 执行命令\n" + "\n" + "INSERT 模式按键:\n" + " ESC - 进入 NORMAL 模式\n" + " Enter - 发送消息\n" + " Backspace - 删除字符\n" + " Ctrl+W - 删除上个单词\n" + " Ctrl+U - 删除整行\n" + " Ctrl+C - 进入 NORMAL 模式\n" + "\n" + "NORMAL 模式按键:\n" + " 默认停在最新消息\n" + " 未向上翻阅时自动跟随最新消息\n" + " i - 返回 INSERT 模式\n" + " : - 进入 COMMAND 模式\n" + " j/k - 向下/上滚动一行\n" + " Ctrl+D/U - 向下/上滚动半页\n" + " Ctrl+F/B - 向下/上滚动整页\n" + " PgDn/PgUp - 向下/上滚动整页\n" + " End/Home - 跳到底部/顶部\n" + " g/G - 跳到顶部/底部\n" + " ? - 显示完整按键参考\n" + " Ctrl+C - 退出聊天\n" + "\n" + "可用命令:\n"); + command_catalog_append_full(buffer, buf_size, pos, lang); + buffer_appendf(buffer, buf_size, pos, + "\n" + "特殊消息:\n" + " /me <动作> - 发送动作 (如 /me 挥手)\n" + " @用户名 - 提及用户 (响铃+高亮)\n" + "\n" + "帮助界面按键:\n" + " q, ESC - 关闭帮助\n" + " j/k - 向下/上滚动\n" + " Ctrl+D/U - 向下/上滚动半页\n" + " Ctrl+F/B - 向下/上滚动整页\n" + " g/G - 跳到顶部/底部\n" + " e/z - 切换英文/中文\n"); } diff --git a/src/manual.c b/src/manual.c index c135b13..3d4ec43 100644 --- a/src/manual.c +++ b/src/manual.c @@ -5,6 +5,5 @@ void manual_append_interactive_panel(char *buffer, size_t buf_size, size_t *pos, help_lang_t lang) { if (!buffer || !pos) return; - buffer_appendf(buffer, buf_size, pos, "%s", - manual_text_interactive(lang)); + manual_text_append_interactive(buffer, buf_size, pos, lang); } diff --git a/src/manual_text.c b/src/manual_text.c index f62a6e4..d847fc0 100644 --- a/src/manual_text.c +++ b/src/manual_text.c @@ -1,47 +1,51 @@ #include "manual_text.h" -const char *manual_text_interactive(help_lang_t lang) { +#include "command_catalog.h" + +void manual_text_append_interactive(char *buffer, size_t buf_size, + size_t *pos, help_lang_t lang) { if (lang == LANG_ZH) { - return "\033[1;36mTNT(1) 帮助\033[0m\n" - "\n" - "\033[1;37m名称\033[0m\n" - " TNT - SSH 终端聊天室\n" - "\n" - "\033[1;37m使用\033[0m\n" - " 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n" - " : 运行命令;? 打开完整按键参考\n" - "\n" - "\033[1;37m命令\033[0m\n" - " :users, :last [N], :search <词>\n" - " :msg <用户> <文本>, :inbox, :nick <名字>\n" - " :mute-joins, :clear, :q\n" - "\n" - "\033[1;37m语言\033[0m\n" - " :lang 显示当前语言\n" - " :lang en|zh 切换语言\n" - "\n" - "\033[1;37m参见\033[0m\n" - " ? 完整按键参考\n"; + buffer_appendf(buffer, buf_size, pos, + "\033[1;36mTNT(1) 帮助\033[0m\n" + "\n" + "\033[1;37m名称\033[0m\n" + " TNT - SSH 终端聊天室\n" + "\n" + "\033[1;37m使用\033[0m\n" + " 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n" + " : 运行命令;? 打开完整按键参考\n" + "\n" + "\033[1;37m命令\033[0m\n"); + command_catalog_append_manual(buffer, buf_size, pos, lang); + buffer_appendf(buffer, buf_size, pos, + "\n" + "\033[1;37m语言\033[0m\n" + " :lang 显示当前语言\n" + " :lang en|zh 切换语言\n" + "\n" + "\033[1;37m参见\033[0m\n" + " ? 完整按键参考\n"); + return; } - return "\033[1;36mTNT(1) help\033[0m\n" - "\n" - "\033[1;37mName\033[0m\n" - " TNT - SSH terminal chat room\n" - "\n" - "\033[1;37mUse\033[0m\n" - " Type a message and press Enter; Esc browses; G latest; i types\n" - " : runs commands; ? opens the full key reference\n" - "\n" - "\033[1;37mCommands\033[0m\n" - " :users, :last [N], :search \n" - " :msg , :inbox, :nick \n" - " :mute-joins, :clear, :q\n" - "\n" - "\033[1;37mLanguage\033[0m\n" - " :lang show current language\n" - " :lang en|zh switch language\n" - "\n" - "\033[1;37mSee also\033[0m\n" - " ? full key reference\n"; + buffer_appendf(buffer, buf_size, pos, + "\033[1;36mTNT(1) help\033[0m\n" + "\n" + "\033[1;37mName\033[0m\n" + " TNT - SSH terminal chat room\n" + "\n" + "\033[1;37mUse\033[0m\n" + " Type a message and press Enter; Esc browses; G latest; i types\n" + " : runs commands; ? opens the full key reference\n" + "\n" + "\033[1;37mCommands\033[0m\n"); + command_catalog_append_manual(buffer, buf_size, pos, lang); + buffer_appendf(buffer, buf_size, pos, + "\n" + "\033[1;37mLanguage\033[0m\n" + " :lang show current language\n" + " :lang en|zh switch language\n" + "\n" + "\033[1;37mSee also\033[0m\n" + " ? full key reference\n"); } diff --git a/src/tui.c b/src/tui.c index 2aaf456..8334564 100644 --- a/src/tui.c +++ b/src/tui.c @@ -777,11 +777,11 @@ void tui_render_help(client_t *client) { } buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n"); - /* Help content */ - const char *help_text = help_text_full(client->help_lang); char help_copy[8192]; - strncpy(help_copy, help_text, sizeof(help_copy) - 1); - help_copy[sizeof(help_copy) - 1] = '\0'; + size_t help_pos = 0; + help_copy[0] = '\0'; + help_text_append_full(help_copy, sizeof(help_copy), &help_pos, + client->help_lang); /* Split into lines and display with scrolling */ char *lines[100]; diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 59e4a13..bea6a5f 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -13,6 +13,7 @@ endif UTF8_SRC = ../../src/utf8.c MESSAGE_SRC = ../../src/message.c COMMON_SRC = ../../src/common.c +COMMAND_CATALOG_SRC = ../../src/command_catalog.c CLI_TEXT_SRC = ../../src/cli_text.c CHAT_ROOM_SRC = ../../src/chat_room.c HISTORY_VIEW_SRC = ../../src/history_view.c @@ -22,7 +23,7 @@ HELP_TEXT_SRC = ../../src/help_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c RATELIMIT_SRC = ../../src/ratelimit.c -TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_help_text test_manual_text test_cli_text test_ratelimit +TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_help_text test_manual_text test_cli_text test_ratelimit .PHONY: all clean run @@ -46,10 +47,13 @@ test_i18n: test_i18n.c $(I18N_SRC) test_system_message: test_system_message.c $(SYSTEM_MESSAGE_SRC) $(I18N_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMON_SRC) +test_command_catalog: test_command_catalog.c $(COMMAND_CATALOG_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) +test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC) @@ -77,6 +81,9 @@ run: all @echo "=== Running System Message Tests ===" ./test_system_message @echo "" + @echo "=== Running Command Catalog Tests ===" + ./test_command_catalog + @echo "" @echo "=== Running Help Text Tests ===" ./test_help_text @echo "" diff --git a/tests/unit/test_command_catalog.c b/tests/unit/test_command_catalog.c new file mode 100644 index 0000000..222889d --- /dev/null +++ b/tests/unit/test_command_catalog.c @@ -0,0 +1,86 @@ +/* Unit tests for command catalog names, aliases, and generated help text */ + +#include "../../include/command_catalog.h" +#include +#include +#include + +#define TEST(name) static void test_##name() +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("✓\n"); \ + tests_passed++; \ +} while(0) + +static int tests_passed = 0; + +TEST(matches_canonical_names_and_aliases) { + tnt_command_id_t id; + const char *args; + + assert(command_catalog_match("users", &id, &args)); + assert(id == TNT_COMMAND_USERS); + assert(strcmp(args, "") == 0); + + assert(command_catalog_match("list", &id, &args)); + assert(id == TNT_COMMAND_USERS); + + assert(command_catalog_match("msg alice hello", &id, &args)); + assert(id == TNT_COMMAND_MSG); + assert(strcmp(args, "alice hello") == 0); + + assert(command_catalog_match("w alice hello", &id, &args)); + assert(id == TNT_COMMAND_MSG); + assert(strcmp(args, "alice hello") == 0); + + assert(command_catalog_match("language zh", &id, &args)); + assert(id == TNT_COMMAND_LANG); + assert(strcmp(args, "zh") == 0); +} + +TEST(rejects_arguments_for_no_arg_commands) { + tnt_command_id_t id; + const char *args; + + assert(!command_catalog_match("users extra", &id, &args)); + assert(!command_catalog_match("help now", &id, &args)); + assert(!command_catalog_match("q now", &id, &args)); +} + +TEST(suggests_from_catalog_aliases) { + assert(strcmp(command_catalog_suggest("hlep"), "help") == 0); + assert(strcmp(command_catalog_suggest("usres"), "users") == 0); + assert(strcmp(command_catalog_suggest("laguage"), "lang") == 0); + assert(command_catalog_suggest("not-even-close") == NULL); +} + +TEST(generates_localized_help_sections) { + char en[4096] = {0}; + char zh[4096] = {0}; + size_t en_pos = 0; + size_t zh_pos = 0; + + command_catalog_append_full(en, sizeof(en), &en_pos, LANG_EN); + command_catalog_append_full(zh, sizeof(zh), &zh_pos, LANG_ZH); + + assert(strstr(en, ":users, :list, :who") != NULL); + assert(strstr(en, "Show online users") != NULL); + assert(strstr(en, ":support") == NULL); + + assert(strstr(zh, ":users, :list, :who") != NULL); + assert(strstr(zh, "显示在线用户") != NULL); + assert(strstr(zh, ":support") == NULL); +} + +int main(void) { + printf("Running command catalog unit tests...\n\n"); + + RUN_TEST(matches_canonical_names_and_aliases); + RUN_TEST(rejects_arguments_for_no_arg_commands); + RUN_TEST(suggests_from_catalog_aliases); + RUN_TEST(generates_localized_help_sections); + + printf("\n✓ All %d tests passed!\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_help_text.c b/tests/unit/test_help_text.c index 90cd67a..5887c85 100644 --- a/tests/unit/test_help_text.c +++ b/tests/unit/test_help_text.c @@ -16,8 +16,13 @@ static int tests_passed = 0; TEST(full_help_matches_language) { - const char *en = help_text_full(LANG_EN); - const char *zh = help_text_full(LANG_ZH); + char en[8192] = {0}; + char zh[8192] = {0}; + size_t en_pos = 0; + size_t zh_pos = 0; + + help_text_append_full(en, sizeof(en), &en_pos, LANG_EN); + help_text_append_full(zh, sizeof(zh), &zh_pos, LANG_ZH); assert(strstr(en, "TNT KEY REFERENCE") != NULL); assert(strstr(en, "AVAILABLE COMMANDS") != NULL); diff --git a/tests/unit/test_manual_text.c b/tests/unit/test_manual_text.c index 2eff4fc..870dde6 100644 --- a/tests/unit/test_manual_text.c +++ b/tests/unit/test_manual_text.c @@ -29,14 +29,20 @@ static int count_lines(const char *text) { } TEST(interactive_manual_matches_language) { - const char *en = manual_text_interactive(LANG_EN); - const char *zh = manual_text_interactive(LANG_ZH); + char en[4096] = {0}; + char zh[4096] = {0}; + size_t en_pos = 0; + size_t zh_pos = 0; + + manual_text_append_interactive(en, sizeof(en), &en_pos, LANG_EN); + manual_text_append_interactive(zh, sizeof(zh), &zh_pos, LANG_ZH); assert(strstr(en, "TNT(1) help") != NULL); assert(strstr(en, "Use") != NULL); assert(strstr(en, "Commands") != NULL); assert(strstr(en, ":lang en|zh") != NULL); assert(strstr(en, ":mute-joins") != NULL); + assert(strstr(en, ":mute-joins, :clear, :q") != NULL); assert(strstr(en, ":support") == NULL); assert(strstr(en, ":commands") == NULL); assert(count_lines(en) <= 20); @@ -46,6 +52,7 @@ TEST(interactive_manual_matches_language) { assert(strstr(zh, "命令") != NULL); assert(strstr(zh, ":lang en|zh") != NULL); assert(strstr(zh, ":mute-joins") != NULL); + assert(strstr(zh, ":mute-joins, :clear, :q") != NULL); assert(strstr(zh, ":support") == NULL); assert(strstr(zh, ":commands") == NULL); assert(count_lines(zh) <= 20);