diff --git a/README.md b/README.md index 76097e6..8a69f70 100644 --- a/README.md +++ b/README.md @@ -250,9 +250,9 @@ TNT/ ├── src/ # source code │ ├── main.c # entry point │ ├── cli_text.c # startup CLI help and option text -│ ├── command_catalog.c # command metadata +│ ├── command_catalog.c # command metadata, usage, and argument shape │ ├── commands.c # COMMAND-mode command dispatch -│ ├── exec_catalog.c # SSH exec command matching and metadata +│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape │ ├── exec.c # SSH exec command dispatch │ ├── ssh_server.c # SSH server implementation │ ├── bootstrap.c # SSH authentication and session bootstrap diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6f044de..4a139da 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,9 @@ public documentation does not imply a specific production host. - Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so `src/exec.c` no longer duplicates `--json` and required-message validation. +- Moved interactive command usage text and first-pass argument-shape checks + into `command_catalog`, so known commands with bad arguments now show usage + instead of unknown-command guidance. - Renamed the internal language state from help-oriented names to UI-language names (`ui_lang_t`, `client->ui_lang`, and `i18n_*_ui_lang`) so future i18n work has a correctly named seam. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c727b4f..3be370a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -37,9 +37,9 @@ make check ``` main.c → entry point, signal handling cli_text.c → startup CLI text -command_catalog.c → COMMAND-mode command metadata +command_catalog.c → COMMAND-mode command metadata, usage, and argument shape commands.c → COMMAND-mode command dispatch -exec_catalog.c → SSH exec command matching and help metadata +exec_catalog.c → SSH exec command matching, usage, and argument shape exec.c → SSH exec command dispatch ssh_server.c → SSH listener setup bootstrap.c → SSH authentication/session bootstrap @@ -83,7 +83,8 @@ utf8.c → UTF-8 string handling ## Adding Features -1. Add interactive command metadata in `src/command_catalog.c`. +1. Add interactive command metadata, usage text, and argument shape in + `src/command_catalog.c`. 2. Add interactive command behavior in `src/commands.c`. 3. Add SSH exec metadata in `src/exec_catalog.c` and dispatch in `src/exec.c` only when the feature should be scriptable. diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index c68481f..0f19bd0 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -46,12 +46,12 @@ INSERT MODE STRUCTURE src/main.c entry, signals src/cli_text.c startup CLI text - src/command_catalog.c command metadata + src/command_catalog.c command metadata, usage, argument shape src/ssh_server.c SSH listener and server setup src/bootstrap.c SSH auth/session bootstrap src/chat_room.c broadcast and room state src/commands.c COMMAND-mode command dispatch - src/exec_catalog.c SSH exec command matching and metadata + src/exec_catalog.c SSH exec command matching, usage, argument shape src/exec.c SSH exec command dispatch src/message.c persistence, search src/history_view.c message viewport / scroll state diff --git a/include/command_catalog.h b/include/command_catalog.h index 891078c..c285927 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -22,16 +22,18 @@ 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); +bool command_catalog_args_valid(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, ui_lang_t lang); void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos, ui_lang_t lang); +void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos, + tnt_command_id_t id, ui_lang_t lang); #endif /* COMMAND_CATALOG_H */ diff --git a/include/i18n.h b/include/i18n.h index c54fdcc..b6c105b 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -29,19 +29,15 @@ typedef enum { I18N_SYSTEM_LEAVE_FORMAT, I18N_SYSTEM_NICK_FORMAT, I18N_USERS_TITLE, - I18N_MSG_USAGE, I18N_MSG_SENT_FORMAT, I18N_MSG_USER_NOT_FOUND_FORMAT, I18N_INBOX_TITLE, I18N_INBOX_EMPTY, - I18N_NICK_USAGE, I18N_NICK_INVALID, I18N_NICK_TAKEN_FORMAT, I18N_NICK_UNCHANGED, I18N_NICK_CHANGED_FORMAT, - I18N_LAST_USAGE, I18N_LAST_HEADER_FORMAT, - I18N_SEARCH_USAGE, I18N_SEARCH_HEADER_FORMAT, I18N_MUTE_JOINS_FORMAT, I18N_MUTE_JOINS_MUTED, diff --git a/src/command_catalog.c b/src/command_catalog.c index a60ff0f..53d87e0 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -10,76 +10,106 @@ typedef struct { const char *summary_zh; const char *manual_usage_en; const char *manual_usage_zh; + const char *error_usage_en; + const char *error_usage_zh; int manual_group; + bool no_args; + bool requires_args; } command_catalog_entry_t; static const command_catalog_entry_t entries[] = { { - {TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}, false}, + {TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}}, ":users, :list, :who", ":users, :list, :who", "Show online users", "显示在线用户", - ":users", ":users", 1 + ":users", ":users", + "Usage: users\n", "用法: users\n", + 1, true, false }, { - {TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}, true}, + {TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}}, ":msg , :w ", ":msg , :w ", "Send private message", "发送私信", - ":msg ", ":msg ", 2 + ":msg ", ":msg ", + "Usage: msg \n" + " w \n", + "用法: msg \n" + " w \n", + 2, false, true }, { - {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}, false}, + {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}}, ":inbox", ":inbox", "Show private messages", "查看私信", - ":inbox", ":inbox", 2 + ":inbox", ":inbox", + "Usage: inbox\n", "用法: inbox\n", + 2, true, false }, { - {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}, true}, + {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}}, ":nick , :name ", ":nick , :name ", "Change nickname", "更改昵称", - ":nick ", ":nick ", 2 + ":nick ", ":nick ", + "Usage: nick \n", "用法: nick \n", + 2, false, true }, { - {TNT_COMMAND_LAST, "last", {"last", NULL}, true}, + {TNT_COMMAND_LAST, "last", {"last", NULL}}, ":last [N]", ":last [N]", "Show last N messages (max 50)", "显示最后 N 条消息(最多50)", - ":last [N]", ":last [N]", 1 + ":last [N]", ":last [N]", + "Usage: last [N] (N: 1-50, default 10)\n", + "用法: last [N] (N: 1-50,默认 10)\n", + 1, false, false }, { - {TNT_COMMAND_SEARCH, "search", {"search", NULL}, true}, + {TNT_COMMAND_SEARCH, "search", {"search", NULL}}, ":search ", ":search ", "Search message history", "搜索消息历史", - ":search ", ":search ", 1 + ":search ", ":search ", + "Usage: search \n", "用法: search \n", + 1, false, true }, { - {TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}, false}, + {TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}}, ":mute-joins, :mute", ":mute-joins, :mute", "Toggle join/leave notices", "切换加入/离开提示", - ":mute-joins", ":mute-joins", 3 + ":mute-joins", ":mute-joins", + "Usage: mute-joins\n", "用法: mute-joins\n", + 3, true, false }, { - {TNT_COMMAND_HELP, "help", {"help", NULL}, false}, + {TNT_COMMAND_HELP, "help", {"help", NULL}}, ":help", ":help", "Show concise manual", "显示简明手册", - NULL, NULL, 0 + NULL, NULL, + "Usage: help\n", "用法: help\n", + 0, true, false }, { - {TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}, true}, + {TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}}, ":lang ", ":lang ", "Switch UI language", "切换界面语言", - NULL, NULL, 0 + NULL, NULL, + "Usage: lang \n", "用法: lang \n", + 0, false, false }, { - {TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}, false}, + {TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}}, ":clear, :cls", ":clear, :cls", "Clear command output", "清空命令输出", - ":clear", ":clear", 3 + ":clear", ":clear", + "Usage: clear\n", "用法: clear\n", + 3, true, false }, { - {TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}, false}, + {TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}}, ":q, :quit, :exit", ":q, :quit, :exit", "Disconnect", "断开连接", - ":q", ":q", 3 + ":q", ":q", + "Usage: q\n", "用法: q\n", + 3, true, false } }; @@ -177,10 +207,6 @@ bool command_catalog_match(const char *line, tnt_command_id_t *id, 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; } @@ -194,6 +220,22 @@ bool command_catalog_match(const char *line, tnt_command_id_t *id, return false; } +bool command_catalog_args_valid(tnt_command_id_t id, const char *args) { + const command_catalog_entry_t *entry = entry_for_id(id); + args = skip_spaces(args); + + if (!entry) { + return false; + } + if (entry->no_args) { + return !args || args[0] == '\0'; + } + if (entry->requires_args) { + return args && args[0] != '\0'; + } + return true; +} + const char *command_catalog_suggest(const char *name) { const char *best = NULL; int best_distance = 99; @@ -257,3 +299,17 @@ void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos, buffer_appendf(buffer, buf_size, pos, "\n"); } } + +void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos, + tnt_command_id_t id, ui_lang_t lang) { + const command_catalog_entry_t *entry = entry_for_id(id); + const char *usage; + + if (!entry) { + return; + } + + usage = lang == UI_LANG_ZH ? entry->error_usage_zh + : entry->error_usage_en; + buffer_appendf(buffer, buf_size, pos, "%s", usage); +} diff --git a/src/commands.c b/src/commands.c index 746f3ad..035e518 100644 --- a/src/commands.c +++ b/src/commands.c @@ -47,6 +47,11 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos, } } +static void append_command_usage(char *output, size_t buf_size, size_t *pos, + tnt_command_id_t id, ui_lang_t lang) { + command_catalog_append_usage(output, buf_size, pos, id, lang); +} + void commands_dispatch(client_t *client) { char cmd_buf[256]; strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); @@ -107,6 +112,12 @@ void commands_dispatch(client_t *client) { goto cmd_done; } + if (!command_catalog_args_valid(command_id, arg)) { + append_command_usage(output, sizeof(output), &pos, command_id, + client->ui_lang); + goto cmd_done; + } + if (command_id == TNT_COMMAND_USERS) { pthread_rwlock_rdlock(&g_room->lock); int total = g_room->client_count; @@ -171,8 +182,8 @@ void commands_dispatch(client_t *client) { while (*rest == ' ') rest++; if (target_name[0] == '\0' || rest[0] == '\0') { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, I18N_MSG_USAGE)); + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_MSG, client->ui_lang); } else { bool found = false; client_t *target = NULL; @@ -267,8 +278,8 @@ void commands_dispatch(client_t *client) { while (*new_name == ' ') new_name++; if (new_name[0] == '\0') { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, I18N_NICK_USAGE)); + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_NICK, client->ui_lang); } else if (!is_valid_username(new_name)) { buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->ui_lang, I18N_NICK_INVALID)); @@ -331,8 +342,8 @@ void commands_dispatch(client_t *client) { char *endp; long val = strtol(arg, &endp, 10); if (*endp != '\0' || val < 1 || val > 50) { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, I18N_LAST_USAGE)); + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_LAST, client->ui_lang); goto cmd_done; } n = (int)val; @@ -357,8 +368,8 @@ void commands_dispatch(client_t *client) { const char *query = arg; while (*query == ' ') query++; if (*query == '\0') { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, I18N_SEARCH_USAGE)); + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_SEARCH, client->ui_lang); } else { message_t *found = NULL; int found_count = message_search(query, &found, 15); diff --git a/src/i18n_text.c b/src/i18n_text.c index 31ff12a..8568163 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -106,12 +106,6 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = { "Online users", "在线用户" }, - [I18N_MSG_USAGE] = { - "Usage: msg \n" - " w \n", - "用法: msg \n" - " w \n" - }, [I18N_MSG_SENT_FORMAT] = { "Private message sent to %s\n", "私信已发送给 %s\n" @@ -128,10 +122,6 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = { "(empty)", "(空)" }, - [I18N_NICK_USAGE] = { - "Usage: nick \n", - "用法: nick \n" - }, [I18N_NICK_INVALID] = { "Invalid username\n", "用户名无效\n" @@ -148,18 +138,10 @@ static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = { "Nickname changed: %s -> %s\n", "昵称已修改: %s -> %s\n" }, - [I18N_LAST_USAGE] = { - "Usage: last [N] (N: 1-50, default 10)\n", - "用法: last [N] (N: 1-50,默认 10)\n" - }, [I18N_LAST_HEADER_FORMAT] = { "--- Last %d message(s) ---\n", "--- 最近 %d 条消息 ---\n" }, - [I18N_SEARCH_USAGE] = { - "Usage: search \n", - "用法: search \n" - }, [I18N_SEARCH_HEADER_FORMAT] = { "--- Search: \"%s\" (%d match(es)) ---\n", "--- 搜索: \"%s\" (%d 条匹配) ---\n" diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index e385bec..54aa175 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -305,6 +305,20 @@ send -- "last 999\r" expect "Usage: last \\[N\\]" expect "q:close" send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "users extra\r" +expect "Usage: users" +expect "q:close" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "help now\r" +expect "Usage: help" +expect "q:close" +send -- "q" sleep 0.2 send -- "\003" sleep 0.2 diff --git a/tests/unit/test_command_catalog.c b/tests/unit/test_command_catalog.c index 5445a9c..575759e 100644 --- a/tests/unit/test_command_catalog.c +++ b/tests/unit/test_command_catalog.c @@ -40,13 +40,37 @@ TEST(matches_canonical_names_and_aliases) { assert(strcmp(args, "zh") == 0); } -TEST(rejects_arguments_for_no_arg_commands) { +TEST(matches_known_commands_before_argument_validation) { 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)); + assert(command_catalog_match("users extra", &id, &args)); + assert(id == TNT_COMMAND_USERS); + assert(strcmp(args, "extra") == 0); + + assert(command_catalog_match("help now", &id, &args)); + assert(id == TNT_COMMAND_HELP); + assert(strcmp(args, "now") == 0); + + assert(command_catalog_match("q now", &id, &args)); + assert(id == TNT_COMMAND_QUIT); + assert(strcmp(args, "now") == 0); +} + +TEST(validates_argument_shapes) { + assert(command_catalog_args_valid(TNT_COMMAND_USERS, NULL)); + assert(!command_catalog_args_valid(TNT_COMMAND_USERS, "extra")); + assert(command_catalog_args_valid(TNT_COMMAND_HELP, NULL)); + assert(!command_catalog_args_valid(TNT_COMMAND_HELP, "now")); + + assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL)); + assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello")); + assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, "")); + assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle")); + + assert(command_catalog_args_valid(TNT_COMMAND_LAST, NULL)); + assert(command_catalog_args_valid(TNT_COMMAND_LAST, "999")); + assert(command_catalog_args_valid(TNT_COMMAND_LANG, "fr")); } TEST(suggests_from_catalog_aliases) { @@ -81,13 +105,31 @@ TEST(generates_localized_help_sections) { assert_ascii_angle_placeholders(zh); } +TEST(generates_localized_usage) { + char en[256] = {0}; + char zh[256] = {0}; + size_t en_pos = 0; + size_t zh_pos = 0; + + command_catalog_append_usage(en, sizeof(en), &en_pos, + TNT_COMMAND_LAST, UI_LANG_EN); + command_catalog_append_usage(zh, sizeof(zh), &zh_pos, + TNT_COMMAND_MSG, UI_LANG_ZH); + + assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0); + assert(strcmp(zh, "用法: msg \n" + " w \n") == 0); +} + 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(matches_known_commands_before_argument_validation); + RUN_TEST(validates_argument_shapes); RUN_TEST(suggests_from_catalog_aliases); RUN_TEST(generates_localized_help_sections); + RUN_TEST(generates_localized_usage); printf("\n✓ All %d tests passed!\n", tests_passed); return 0; diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 889cf3c..5cd4e78 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -111,12 +111,6 @@ TEST(text_lookup_matches_language) { "idle timeout") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT), "空闲超时") != NULL); - assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_USAGE), - "msg ") != NULL); - assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_USAGE), - "msg ") != NULL); - assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_USAGE), - "<用户>") == NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_SENT_FORMAT), "Private message sent") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT), @@ -125,14 +119,10 @@ TEST(text_lookup_matches_language) { "Private messages") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE), "私信") != NULL); - assert(strstr(i18n_text(UI_LANG_ZH, I18N_NICK_USAGE), - "nick ") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT), "Search") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT), "搜索") != NULL); - assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_USAGE), - "search ") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT), "lang ") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),