Files
Hermit/src/ctl_parse.c
T
WHC 2fde898da0
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s
Usability pass: tool registry, TUI polish, saved keys and settings
Tools
- Table-driven tool registry with permission classes and default/minimal
  profiles; new tools: edit_file, list_dir, glob, grep, delete_file,
  move_file, todo_write, web_fetch, ask_user (plus read_file paging).
- Built-in Myers diff; write tools return unified diffs.
- Persistent permission rules (.agent/permissions.json), per-turn undo
  snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80%
  of the model context, cancellable run_shell with process-group kill.

TUI
- Table-driven slash commands: /provider /key /effort /status /tools
  /permissions /yolo /undo /retry /diff /export /copy /rename /delete
  /init /max-steps /todo, with / and @ completion.
- Permission modal with diff and prefix rules, ask_user modal, todo
  panel, header with model/effort/context, bracketed paste, line editing,
  wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits.

Startup and configuration
- API keys are entered in the TUI (hidden) and saved per provider in
  <config>/credentials (0600); env variables are only a fallback.
- Last provider, per-provider model and reasoning effort are remembered
  in <config>/settings.json; --effort/-e and /effort send
  reasoning_effort (low|medium|high|max) to models that accept it.
- DeepSeek preset uses deepseek-flash / deepseek-v4-pro.
- Session flags --new/--continue/--resume/--list-sessions, --yolo,
  --confirm.

Compatibility and tests
- ncurses detection, macOS/Homebrew support, make install, XDG config
  dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS).
- Unit tests for diff, fs tools, registry, undo/perms and config;
  e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
2026-10-05 01:47:01 +08:00

555 lines
14 KiB
C

#include "ctl.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CTL_MAX_TAG 32
typedef enum {
ST_TEXT = 0, /* outside a call; accumulate text lines */
ST_LINE, /* collecting a line (call/arg/end or text) */
ST_ARG_BODY, /* reading nbytes of arg body */
ST_HEREDOC, /* reading heredoc lines until the tag line */
ST_ERROR
} ctl_state_t;
struct ctl_parser {
ctl_on_event_fn on_event;
void *userdata;
ctl_state_t state;
char line[CTL_MAX_LINE];
size_t line_len;
ctl_tool_call_t call;
int in_call;
/* current arg being filled */
char arg_key[CTL_MAX_KEY];
char *arg_buf;
size_t arg_cap;
size_t arg_got;
size_t arg_need;
char tag[CTL_MAX_TAG + 1];
size_t pending;
ctl_parser_stats_t st;
char errbuf[256];
/* last length-prefixed arg, for actionable contract-error messages */
char last_nkey[CTL_MAX_KEY];
size_t last_nbytes;
};
const char *ctl_error_kind_name(ctl_error_kind_t k) {
switch (k) {
case CTL_EK_NONE:
return "none";
case CTL_EK_BAD_CALL:
return "bad_call";
case CTL_EK_BAD_ARG:
return "bad_arg";
case CTL_EK_UNEXPECTED_LINE:
return "unexpected_line";
case CTL_EK_BODY_EOF:
return "body_eof";
case CTL_EK_HEREDOC_EOF:
return "heredoc_eof";
case CTL_EK_LINE_TOO_LONG:
return "line_too_long";
case CTL_EK_LIMIT:
return "limit";
case CTL_EK_OOM:
return "oom";
}
return "unknown";
}
static void bump_pending(ctl_parser_t *p, size_t n) {
p->pending = n;
if (n > p->st.pending_peak)
p->st.pending_peak = n;
}
static void emit(ctl_parser_t *p, ctl_event_t *ev) {
if (p->on_event)
p->on_event(ev, p->userdata);
}
static void fail(ctl_parser_t *p, ctl_error_kind_t kind, const char *msg) {
snprintf(p->errbuf, sizeof(p->errbuf), "%s", msg);
p->state = ST_ERROR;
p->st.error_kind = kind;
ctl_event_t ev = {0};
ev.type = CTL_EV_ERROR;
ev.error = p->errbuf;
emit(p, &ev);
}
/* free args in current call */
static void clear_call_args(ctl_tool_call_t *c) {
for (int i = 0; i < c->nargs; i++) {
free(c->args[i].value);
c->args[i].value = NULL;
c->args[i].value_len = 0;
}
c->nargs = 0;
c->id[0] = 0;
c->name[0] = 0;
}
static void reset_arg_body(ctl_parser_t *p) {
free(p->arg_buf);
p->arg_buf = NULL;
p->arg_cap = 0;
p->arg_got = 0;
p->arg_need = 0;
p->arg_key[0] = 0;
p->tag[0] = 0;
}
ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata) {
ctl_parser_t *p = calloc(1, sizeof(*p));
if (!p)
return NULL;
p->on_event = on_event;
p->userdata = userdata;
p->state = ST_TEXT;
return p;
}
void ctl_parser_destroy(ctl_parser_t *p) {
if (!p)
return;
clear_call_args(&p->call);
reset_arg_body(p);
free(p);
}
void ctl_parser_reset(ctl_parser_t *p) {
if (!p)
return;
clear_call_args(&p->call);
reset_arg_body(p);
p->state = ST_TEXT;
p->line_len = 0;
p->in_call = 0;
p->pending = 0;
p->st.error_kind = CTL_EK_NONE;
p->last_nkey[0] = 0;
/* keep counters / pending_peak for experiments */
}
size_t ctl_parser_pending(const ctl_parser_t *p) { return p ? p->pending : 0; }
size_t ctl_parser_pending_peak(const ctl_parser_t *p) {
return p ? p->st.pending_peak : 0;
}
void ctl_parser_stats(const ctl_parser_t *p, ctl_parser_stats_t *out) {
if (!out)
return;
if (!p) {
memset(out, 0, sizeof(*out));
return;
}
*out = p->st;
}
static int starts_with(const char *s, const char *pfx) {
return strncmp(s, pfx, strlen(pfx)) == 0;
}
static int parse_call_line(ctl_parser_t *p, const char *line) {
/* call <id> <name> */
const char *s = line + 5;
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
return -1;
const char *sp = strchr(s, ' ');
if (!sp)
return -1;
size_t idlen = (size_t)(sp - s);
if (idlen == 0 || idlen >= CTL_MAX_ID)
return -1;
while (*sp == ' ' || *sp == '\t')
sp++;
if (!*sp)
return -1;
size_t nlen = strlen(sp);
while (nlen > 0 && (sp[nlen - 1] == ' ' || sp[nlen - 1] == '\t'))
nlen--;
if (nlen == 0 || nlen >= CTL_MAX_NAME)
return -1;
for (size_t i = 0; i < nlen; i++) {
if (!(isalnum((unsigned char)sp[i]) || sp[i] == '_' || sp[i] == '-'))
return -1;
}
clear_call_args(&p->call);
memcpy(p->call.id, s, idlen);
p->call.id[idlen] = 0;
memcpy(p->call.name, sp, nlen);
p->call.name[nlen] = 0;
p->in_call = 1;
p->st.calls_open++;
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_OPEN;
ev.call = &p->call;
emit(p, &ev);
return 0;
}
static int finish_arg(ctl_parser_t *p) {
int i = p->call.nargs;
snprintf(p->call.args[i].key, CTL_MAX_KEY, "%s", p->arg_key);
if (!p->arg_buf) {
p->arg_buf = malloc(1);
if (!p->arg_buf)
return -1;
}
p->arg_buf[p->arg_got] = 0;
p->call.args[i].value = p->arg_buf;
p->call.args[i].value_len = p->arg_got;
p->arg_buf = NULL; /* ownership moved */
p->arg_cap = 0;
p->call.nargs++;
p->arg_need = 0;
p->arg_got = 0;
p->arg_key[0] = 0;
p->tag[0] = 0;
p->state = ST_LINE;
bump_pending(p, 0);
ctl_event_t ev = {0};
ev.type = CTL_EV_ARG;
ev.call = &p->call;
emit(p, &ev);
return 0;
}
static int valid_tag(const char *t, size_t n) {
if (n == 0 || n > CTL_MAX_TAG)
return 0;
for (size_t i = 0; i < n; i++) {
if (!(isalnum((unsigned char)t[i]) || t[i] == '_'))
return 0;
}
return 1;
}
/* Returns 0 ok, -1 contract error, -2 oom, -3 limit. */
static int parse_arg_header(ctl_parser_t *p, const char *line) {
/* Forms:
* arg <key> <nbytes> → length-prefixed body follows
* arg <key> <<TAG → heredoc body until a line equal to TAG
* arg <key> <value...> → value is rest of line (confirmed now)
*/
const char *s = line + 4;
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
return -1;
const char *sp = strchr(s, ' ');
if (!sp)
return -1;
size_t klen = (size_t)(sp - s);
if (klen == 0 || klen >= CTL_MAX_KEY)
return -1;
while (*sp == ' ' || *sp == '\t')
sp++;
if (!*sp)
return -1;
if (p->call.nargs >= CTL_MAX_ARGS)
return -3;
memcpy(p->arg_key, s, klen);
p->arg_key[klen] = 0;
free(p->arg_buf);
p->arg_buf = NULL;
p->arg_cap = 0;
p->arg_got = 0;
if (sp[0] == '<' && sp[1] == '<') {
const char *t = sp + 2;
while (*t == ' ')
t++;
size_t tl = strlen(t);
while (tl > 0 && (t[tl - 1] == ' ' || t[tl - 1] == '\t'))
tl--;
if (tl >= 2 && (t[0] == '\'' || t[0] == '"') && t[tl - 1] == t[0]) {
t++;
tl -= 2;
}
if (!valid_tag(t, tl))
return -1;
memcpy(p->tag, t, tl);
p->tag[tl] = 0;
p->state = ST_HEREDOC;
p->st.args_heredoc++;
bump_pending(p, 0);
return 0;
}
char *end = NULL;
unsigned long nbytes = strtoul(sp, &end, 10);
int length_prefixed =
(end != sp && isdigit((unsigned char)sp[0]) &&
(*end == '\0' || *end == ' ' || *end == '\t'));
if (length_prefixed && *end != '\0') {
while (*end == ' ' || *end == '\t')
end++;
if (*end != '\0')
length_prefixed = 0; /* "9 apples" is an inline value */
}
if (!length_prefixed) {
size_t vlen = strlen(sp);
if (vlen > CTL_MAX_ARG_BYTES)
return -3;
p->arg_buf = malloc(vlen + 1);
if (!p->arg_buf)
return -2;
memcpy(p->arg_buf, sp, vlen);
p->arg_got = vlen;
p->arg_cap = vlen;
p->st.args_inline++;
return finish_arg(p) == 0 ? 0 : -2;
}
if (nbytes > CTL_MAX_ARG_BYTES)
return -3;
p->arg_need = (size_t)nbytes;
p->st.args_nbytes++;
snprintf(p->last_nkey, sizeof(p->last_nkey), "%s", p->arg_key);
p->last_nbytes = p->arg_need;
if (p->arg_need > 0) {
p->arg_buf = malloc(p->arg_need + 1);
if (!p->arg_buf)
return -2;
p->arg_cap = p->arg_need;
}
if (p->arg_need == 0)
return finish_arg(p) == 0 ? 0 : -2;
p->state = ST_ARG_BODY;
bump_pending(p, p->arg_need);
return 0;
}
static void emit_text_line(ctl_parser_t *p, const char *line, size_t len) {
/* include newline as confirmed text */
char tmp[CTL_MAX_LINE + 1];
if (len + 1 >= sizeof(tmp))
len = sizeof(tmp) - 2;
memcpy(tmp, line, len);
tmp[len] = '\n';
tmp[len + 1] = 0;
ctl_event_t ev = {0};
ev.type = CTL_EV_TEXT;
ev.text = tmp;
ev.text_len = len + 1;
emit(p, &ev);
}
static int is_fence_line(const char *line) {
return strncmp(line, "```", 3) == 0 || strncmp(line, "~~~", 3) == 0;
}
static void emit_ready(ctl_parser_t *p) {
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_READY;
ev.call = &p->call;
p->st.calls_ready++;
emit(p, &ev);
clear_call_args(&p->call);
p->in_call = 0;
bump_pending(p, 0);
p->state = ST_LINE;
}
static int heredoc_line(ctl_parser_t *p, const char *line, size_t len) {
if (strcmp(line, p->tag) == 0)
return finish_arg(p) == 0 ? 0 : -1;
size_t need = p->arg_got + len + 1;
if (need > CTL_MAX_ARG_BYTES) {
fail(p, CTL_EK_LIMIT, "heredoc body too large");
return -1;
}
if (need + 1 > p->arg_cap) {
size_t nc = p->arg_cap ? p->arg_cap * 2 : 256;
while (nc < need + 1)
nc *= 2;
char *nb = realloc(p->arg_buf, nc);
if (!nb) {
fail(p, CTL_EK_OOM, "oom");
return -1;
}
p->arg_buf = nb;
p->arg_cap = nc;
}
memcpy(p->arg_buf + p->arg_got, line, len);
p->arg_got += len;
p->arg_buf[p->arg_got++] = '\n';
bump_pending(p, 0);
return 0;
}
static int handle_line(ctl_parser_t *p, const char *line) {
/* Ignore markdown fences so models can wrap CTL in code blocks. */
if (is_fence_line(line) && !p->in_call) {
bump_pending(p, 0);
return 0;
}
if (line[0] == '\0' || (p->in_call && is_fence_line(line))) {
bump_pending(p, 0);
return 0;
}
if (!p->in_call) {
if (starts_with(line, "call ")) {
if (parse_call_line(p, line) != 0) {
fail(p, CTL_EK_BAD_CALL, "invalid call line");
return -1;
}
p->last_nkey[0] = 0;
bump_pending(p, 0);
return 0;
}
emit_text_line(p, line, strlen(line));
bump_pending(p, 0);
return 0;
}
/* in call */
if (starts_with(line, "arg ")) {
int rc = parse_arg_header(p, line);
if (rc == -2) {
fail(p, CTL_EK_OOM, "oom");
return -1;
}
if (rc == -3) {
fail(p, CTL_EK_LIMIT, "too many args or arg too large");
return -1;
}
if (rc != 0) {
fail(p, CTL_EK_BAD_ARG, "invalid arg header");
return -1;
}
return 0;
}
if (strcmp(line, "end") == 0) {
emit_ready(p);
return 0;
}
char msg[256];
if (p->last_nkey[0])
snprintf(msg, sizeof(msg),
"unexpected line \"%.40s\" inside call; arg %s declared %zu "
"bytes, so its real body was longer (byte count too small)",
line, p->last_nkey, p->last_nbytes);
else
snprintf(msg, sizeof(msg), "unexpected line \"%.40s\" inside call",
line);
fail(p, CTL_EK_UNEXPECTED_LINE, msg);
return -1;
}
ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len) {
if (!p)
return CTL_ERR_CONTRACT;
if (p->state == ST_ERROR)
return CTL_ERR_CONTRACT;
size_t i = 0;
while (i < len) {
if (p->state == ST_ARG_BODY) {
size_t left = p->arg_need - p->arg_got;
size_t take = len - i;
if (take > left)
take = left;
if (take > 0) {
memcpy(p->arg_buf + p->arg_got, data + i, take);
p->arg_got += take;
i += take;
bump_pending(p, p->arg_need - p->arg_got);
}
if (p->arg_got == p->arg_need && finish_arg(p) != 0) {
fail(p, CTL_EK_OOM, "oom");
return CTL_ERR_NOMEM;
}
continue;
}
if (p->state != ST_HEREDOC)
p->state = ST_LINE;
unsigned char c = (unsigned char)data[i++];
if (c == '\r')
continue; /* normalize CRLF */
if (c == '\n') {
p->line[p->line_len] = 0;
int rc = p->state == ST_HEREDOC ? heredoc_line(p, p->line, p->line_len)
: handle_line(p, p->line);
p->line_len = 0;
if (rc != 0)
return p->st.error_kind == CTL_EK_LIMIT ? CTL_ERR_LIMIT
: CTL_ERR_CONTRACT;
continue;
}
if (p->line_len + 1 >= CTL_MAX_LINE) {
fail(p, CTL_EK_LINE_TOO_LONG, "line too long");
return CTL_ERR_LIMIT;
}
p->line[p->line_len++] = (char)c;
bump_pending(p, p->line_len);
}
return CTL_OK;
}
ctl_status_t ctl_parser_finish(ctl_parser_t *p) {
if (!p)
return CTL_ERR_CONTRACT;
if (p->state == ST_ERROR)
return CTL_ERR_CONTRACT;
/* Mid-argument body cannot be confirmed at EOF. */
if (p->state == ST_ARG_BODY) {
char msg[200];
snprintf(msg, sizeof(msg),
"stream ended inside arg %s: declared %zu bytes but only %zu "
"arrived (byte count too large)",
p->arg_key, p->arg_need, p->arg_got);
fail(p, CTL_EK_BODY_EOF, msg);
return CTL_ERR_CONTRACT;
}
if (p->state == ST_HEREDOC) {
p->line[p->line_len] = 0;
if (p->line_len > 0 && strcmp(p->line, p->tag) == 0) {
p->line_len = 0;
if (finish_arg(p) != 0)
return CTL_ERR_NOMEM;
} else {
fail(p, CTL_EK_HEREDOC_EOF, "stream ended before heredoc terminator");
return CTL_ERR_CONTRACT;
}
}
/* Trailing text without final newline is still confirmed text. */
if (!p->in_call && p->line_len > 0) {
p->line[p->line_len] = 0;
emit_text_line(p, p->line, p->line_len);
p->line_len = 0;
}
/* EOF confirms an open call whose args are complete (implicit end).
* Matches paper EOF adequacy: stream end closes the frontier. */
if (p->in_call) {
if (p->line_len > 0) {
p->line[p->line_len] = 0;
if (strcmp(p->line, "end") == 0 || is_fence_line(p->line) ||
p->line[0] == '\0') {
p->line_len = 0;
} else {
fail(p, CTL_EK_UNEXPECTED_LINE,
"unexpected trailing line in call at EOF");
return CTL_ERR_CONTRACT;
}
}
emit_ready(p);
}
bump_pending(p, 0);
return CTL_OK;
}