#include "ctl.h" #include #include #include #include #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 */ 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 → length-prefixed body follows * arg < → 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; }