diff options
Diffstat (limited to 'include')
| -rw-r--r-- | include/clio.h | 1493 | ||||
| -rw-r--r-- | include/dotagents.h | 1282 | ||||
| -rwxr-xr-x | include/escape.h | 272 | ||||
| -rw-r--r-- | include/frontmatter.h | 1058 | ||||
| -rw-r--r-- | include/type.h | 24 |
5 files changed, 4129 insertions, 0 deletions
diff --git a/include/clio.h b/include/clio.h new file mode 100644 index 0000000..8899197 --- /dev/null +++ b/include/clio.h @@ -0,0 +1,1493 @@ +/* clio.h — minimal pseudo-TUI layout layer over escape.h. + * + * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns + * layout: where text goes, how it wraps and clips, and how a composed + * frame is blitted to the terminal with the minimal diff of escape + * sequences. Apps never emit raw escape codes themselves. + * + * Model: + * clio_term the real terminal: size, capabilities, raw mode, alt + * screen, cursor, bracketed-paste/kitty state, key queue, + * and the last flushed frame (for diffing). + * clio_buf an in-memory cell grid (the canvas). Apps compose frames + * here; nothing is written until clio_flush. + * clio_flush diff the canvas against the previous frame and blit only + * changed rows through escape.h. Non-tty (pipe, CI) falls + * back to plain text lines so piped output stays clean. + * input a pure, chunk-fed key decoder (testable without a tty), + * raw-mode key reading with a bounded queue, and a + * single-line editor (clio_readline) that renders into the + * canvas. + * + * Conventions: C99, header-only (static inline), no global state, + * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a + * documented limitation, as in escape.h. + * + * Usage: #include "clio.h". The header is complete on its own; escape.h + * is pulled in for the styling layer. The esc_* function symbols need one + * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the + * test TU itself for zero-link builds). + * + * Layout is column-based: one cell per column, one code point per cell. + * Wide characters (width 2) occupy one cell; a wide character landing + * in the last column may be clipped by the terminal (v1 limitation). + * + * API: + * style: clio_style, clio_style_default, clio_style_eq + * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width, + * clio_text_width, clio_char_start, clio_char_end, + * clio_word_back + * canvas: clio_buf, clio_cell, clio_buf_init/free/clear, + * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region, + * clio_buf_put, clio_buf_box, clio_buf_scroll_up + * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init, + * clio_keydec_feed, clio_keydec_flush + * terminal: clio_term, clio_color_policy, clio_term_init, + * clio_term_resize, clio_term_raw_on/off, + * clio_term_alt_on/off, clio_term_cursor_hide/show, + * clio_term_paste_on/off, clio_term_kitty_on/off, + * clio_term_bell, clio_term_shutdown, clio_term_free + * output: clio_fstyle, clio_flush, clio_cursor + * input: clio_next_key, clio_next_key_stdin, clio_read_key, + * clio_readline + * + * Concurrency: no shared state; each clio_term is independent. + */ +#ifndef CLIO_H +#define CLIO_H + +#include "escape.h" +#include "type.h" + +#include <ctype.h> +#include <limits.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <unistd.h> +#include <wchar.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Style */ +/* ------------------------------------------------------------------ */ + +/* Attribute bits (OR-combined into clio_style.attr). */ +enum { + CLIO_BOLD = 1u, + CLIO_DIM = 2u, + CLIO_ITALIC = 4u, + CLIO_UNDERLINE = 8u, + CLIO_REVERSE = 16u, + CLIO_STRIKE = 32u, +}; + +/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index. + * attr: OR of CLIO_* bits. */ +typedef struct clio_style { + int fg; + int bg; + unsigned attr; +} clio_style; + +static inline clio_style clio_style_default(void) { + clio_style s; + s.fg = -1; + s.bg = -1; + s.attr = 0; + return s; +} + +static inline int clio_style_eq(const clio_style *a, const clio_style *b) { + return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr; +} + +/* ------------------------------------------------------------------ */ +/* Text (UTF-8 + display width) */ +/* ------------------------------------------------------------------ */ + +/* Decode the next code point in [p, end); returns the byte length used. + * Truncated or invalid sequences still consume at least one byte. */ +static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) { + unsigned char b = (unsigned char) p[0]; + size_t n; + if (b < 0x80) + n = 1; + else if ((b & 0xE0) == 0xC0) + n = 2; + else if ((b & 0xF0) == 0xE0) + n = 3; + else if ((b & 0xF8) == 0xF0) + n = 4; + else { + *cp = 0; + return 1; + } + if ((size_t)(end - p) < n) + n = (size_t)(end - p); + u32 c = 0; + for (size_t i = 0; i < n; i++) { + unsigned char ch = (unsigned char) p[i]; + unsigned int mask = + (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u) + : 0x3Fu; + c = (c << 6) | (u32)(ch & mask); + } + *cp = c; + return n; +} + +static inline size_t clio_utf8_next(const char *s, u32 *cp) { + return clio_utf8_next_n(s, s + strlen(s), cp); +} + +/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */ +static inline int clio_utf8_enc(u32 cp, char *out) { + size_t n; + if (cp < 0x80) + n = 1; + else if (cp < 0x800) + n = 2; + else if (cp < 0x10000) + n = 3; + else if (cp <= 0x10FFFF) + n = 4; + else + return -1; + if (cp >= 0xD800 && cp <= 0xDFFF) + return -1; + unsigned char b[4]; + switch (n) { + case 1: + b[0] = (unsigned char) cp; + break; + case 2: + b[0] = (unsigned char)(0xC0 | (cp >> 6)); + b[1] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + case 3: + b[0] = (unsigned char)(0xE0 | (cp >> 12)); + b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + b[2] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + default: + b[0] = (unsigned char)(0xF0 | (cp >> 18)); + b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F)); + b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + b[3] = (unsigned char)(0x80 | (cp & 0x3F)); + break; + } + memcpy(out, b, n); + out[n] = '\0'; + return (int) n; +} + +/* Display width of a code point, locale-independent (wcwidth is not: + * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the + * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth + * + common emoji), 1 = everything else. Simplified W=|F table. */ +static inline size_t clio_cp_width(u32 cp) { + if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F)) + return 0; + if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D || + cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F)) + return 0; + if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) || + (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) || + (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) || + (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) || + (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) || + (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) || + (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD)) + return 2; + return 1; +} + +static inline size_t clio_text_width_n(const char *s, size_t n) { + size_t w = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + u32 cp; + size_t len = clio_utf8_next_n(p, end, &cp); + if (cp != 0) + w += clio_cp_width(cp); + p += len; + } + return w; +} + +static inline size_t clio_text_width(const char *s) { + return clio_text_width_n(s, strlen(s)); +} + +/* Byte offset of the start of the code point containing pos. */ +static inline size_t clio_char_start(const char *s, size_t pos) { + if (pos == 0) + return 0; + size_t i = pos; + while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80) + i--; + return i; +} + +/* Byte offset of the start of the code point immediately before pos + * (pos is a cursor position, i.e. a char boundary). */ +static inline size_t clio_char_before(const char *s, size_t pos) { + size_t i = pos; + if (i == 0) + return 0; + i--; /* last byte belongs to the previous code point */ + while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80) + i--; + return i; +} + +/* Byte offset just past the code point at pos. */ +static inline size_t clio_char_end(const char *s, size_t len, size_t pos) { + if (pos >= len) + return len; + u32 cp; + size_t n = clio_utf8_next_n(s + pos, s + len, &cp); + return pos + n; +} + +/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */ +static inline size_t clio_word_back(const char *s, size_t pos) { + size_t i = pos; + while (i > 0 && s[i - 1] == ' ') + i--; + while (i > 0 && (isalnum((unsigned char) s[i - 1]) || s[i - 1] == '_')) + i--; + while (i > 0 && s[i - 1] == ' ') + i--; + return i; +} + +/* ------------------------------------------------------------------ */ +/* Canvas (cell grid) */ +/* ------------------------------------------------------------------ */ + +/* One column of a row. ch is a code point (0 = blank slot; bg/attr still + * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell + * is {0, 0xFFFF, 0xFFFF, 0}. */ +typedef struct clio_cell { + u32 ch; + u16 fg; + u16 bg; + u16 attr; +} clio_cell; + +typedef struct clio_buf { + clio_cell *cells; + size_t rows; + size_t cols; +} clio_buf; + +static inline u16 clio_style_u16(int v) { + return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v)); +} + +static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) { + c->ch = ch; + c->fg = clio_style_u16(st.fg); + c->bg = clio_style_u16(st.bg); + c->attr = (u16) st.attr; +} + +static inline clio_style clio_cell_style(const clio_cell *c) { + clio_style s; + s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg; + s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg; + s.attr = c->attr; + return s; +} + +static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) { + return memcmp(a, b, sizeof(*a)) == 0; +} + +static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) { + if (rows == 0) + rows = 1; + if (cols == 0) + cols = 1; + b->rows = rows; + b->cols = cols; + b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell)); + if (!b->cells) { + b->rows = b->cols = 0; + return -1; + } + for (size_t i = 0; i < rows * cols; i++) { + b->cells[i].fg = 0xFFFF; + b->cells[i].bg = 0xFFFF; + } + return 0; +} + +static inline void clio_buf_free(clio_buf *b) { + free(b->cells); + b->cells = NULL; + b->rows = b->cols = 0; +} + +static inline void clio_buf_clear(clio_buf *b) { + for (size_t i = 0; i < b->rows * b->cols; i++) { + b->cells[i].ch = 0; + b->cells[i].attr = 0; + b->cells[i].fg = 0xFFFF; + b->cells[i].bg = 0xFFFF; + } +} + +static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) { + if (row >= b->rows || col >= b->cols) + return NULL; + return &b->cells[row * b->cols + col]; +} + +static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1) { + if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows - 1) + r1 = b->rows - 1; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r <= r1; r++) + for (size_t c = c0; c <= c1; c++) { + clio_cell *cell = &b->cells[r * b->cols + c]; + cell->ch = 0; + cell->attr = 0; + cell->fg = 0xFFFF; + cell->bg = 0xFFFF; + } +} + +static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1, u32 ch, + clio_style st) { + if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows - 1) + r1 = b->rows - 1; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r <= r1; r++) + for (size_t c = c0; c <= c1; c++) + clio_cell_set(&b->cells[r * b->cols + c], ch, st); +} + +/* Write text at (row, col); wraps onto the next row (when wrap != 0 and + * rows remain) and clips at the buffer edges. '\n' moves to the next row + * at column 0. Zero-width code points occupy no cell. Returns the number + * of bytes written (including newlines). */ +static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col, + const char *text, clio_style st, int wrap) { + size_t written = 0; + size_t len = strlen(text); + const char *p = text; + while (p < text + len) { + u32 cp; + size_t n = clio_utf8_next_n(p, text + len, &cp); + p += n; + if (cp == '\n') { + row++; + col = 0; + if (row >= b->rows) + break; + written += n; + continue; + } + size_t w = clio_cp_width(cp); + if (w == 0) { + written += n; + continue; + } + if (col + w > b->cols) { + if (!wrap || row + 1 >= b->rows) + break; + row++; + col = 0; + } + for (size_t i = 0; i < w; i++) { + if (col + i >= b->cols) + break; + clio_cell *cell = &b->cells[row * b->cols + (col + i)]; + cell->ch = (i == 0) ? cp : 0; + cell->fg = clio_style_u16(st.fg); + cell->bg = clio_style_u16(st.bg); + cell->attr = (u16) st.attr; + } + col += w; + written += n; + } + return written; +} + +/* Draw a single-character border (ch, e.g. U+2502) around the rectangle + * (r0, c0) with width w and height h. */ +static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w, + size_t h, u32 ch, clio_style st) { + if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0) + return; + if (w > b->cols - c0) + w = b->cols - c0; + if (h > b->rows - r0) + h = b->rows - r0; + for (size_t x = 0; x < w; x++) { + clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st); + clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st); + } + for (size_t y = 1; y + 1 < h; y++) { + clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st); + clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st); + } +} + +/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom + * row becomes blank. */ +static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0, + size_t r1, size_t c1) { + if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols) + return; + if (r1 > b->rows) + r1 = b->rows; + if (c1 > b->cols - 1) + c1 = b->cols - 1; + for (size_t r = r0; r + 1 <= r1; r++) + memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0], + (c1 - c0 + 1) * sizeof(clio_cell)); + for (size_t c = c0; c <= c1; c++) { + clio_cell *cell = &b->cells[r1 * b->cols + c]; + cell->ch = 0; + cell->attr = 0; + cell->fg = 0xFFFF; + cell->bg = 0xFFFF; + } +} + +/* ------------------------------------------------------------------ */ +/* Key types + pure decoder */ +/* ------------------------------------------------------------------ */ + +typedef struct clio_key { + int kind; + u32 cp; /* CLIOK_CHAR: the code point */ +} clio_key; + +enum { + CLIOK_UNKNOWN = 0, + CLIOK_CHAR, + CLIOK_ENTER, + CLIOK_TAB, + CLIOK_BACKTAB, + CLIOK_BACKSPACE, + CLIOK_DELETE, + CLIOK_LEFT, + CLIOK_RIGHT, + CLIOK_UP, + CLIOK_DOWN, + CLIOK_HOME, + CLIOK_END, + CLIOK_PAGEUP, + CLIOK_PAGEDOWN, + CLIOK_ESC, + CLIOK_CTRL_A, + CLIOK_CTRL_C, + CLIOK_CTRL_D, + CLIOK_CTRL_E, + CLIOK_CTRL_K, + CLIOK_CTRL_L, + CLIOK_CTRL_U, + CLIOK_CTRL_W, + CLIOK_CTRL_Y, + CLIOK_CTRL_Z, + CLIOK_CTRL_G, +}; + +/* State machine for decoding raw terminal input into clio_key values. + * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries + * may split UTF-8 or escape sequences); state persists across feeds. */ +typedef struct clio_keydec { + int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */ + int utf8_n; + int utf8_need; + u32 utf8[4]; + int csi_n; + unsigned char csi[32]; + int pasting; +} clio_keydec; + +static inline void clio_keydec_init(clio_keydec *d) { + memset(d, 0, sizeof *d); +} + +static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) { + if (b == 0x1B) { + d->state = 1; + return 0; + } + if (b == 0x0D) { + k->kind = CLIOK_ENTER; + k->cp = 0; + return 1; + } + if (b == 0x0A) { + if (d->pasting) { + k->kind = CLIOK_CHAR; + k->cp = '\n'; + return 1; + } + return 0; /* bare LF outside paste: dead key */ + } + if (b == 0x7F || b == 0x08) { + k->kind = CLIOK_BACKSPACE; + k->cp = 0; + return 1; + } + if (b == 0x09) { + k->kind = CLIOK_TAB; + k->cp = 0; + return 1; + } + if (b < 0x20) { + switch (b) { + case 0x01u: + k->kind = CLIOK_CTRL_A; + break; + case 0x03u: + k->kind = CLIOK_CTRL_C; + d->pasting = 0; /* Ctrl+C interrupts a dangling paste */ + break; + case 0x04u: + k->kind = CLIOK_CTRL_D; + break; + case 0x05u: + k->kind = CLIOK_CTRL_E; + break; + case 0x0Bu: + k->kind = CLIOK_CTRL_K; + break; + case 0x0Cu: + k->kind = CLIOK_CTRL_L; + break; + case 0x15u: + k->kind = CLIOK_CTRL_U; + break; + case 0x17u: + k->kind = CLIOK_CTRL_W; + break; + case 0x19u: + k->kind = CLIOK_CTRL_Y; + break; + case 0x1Au: + k->kind = CLIOK_CTRL_Z; + break; + case 0x07u: + k->kind = CLIOK_CTRL_G; + break; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; + } + if (b < 0x80) { + k->kind = CLIOK_CHAR; + k->cp = b; + return 1; + } + if (b >= 0xC0 && b < 0xF8) { + d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2; + d->utf8[0] = b; + d->utf8_n = 1; + return 0; + } + if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) { + d->utf8[d->utf8_n++] = b; + if (d->utf8_n < d->utf8_need) + return 0; + int n = d->utf8_n; + d->utf8_n = 0; + u32 cp = + d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u); + for (int i = 1; i < n; i++) + cp = (cp << 6) | (d->utf8[i] & 0x3Fu); + k->kind = CLIOK_CHAR; + k->cp = cp; + return 1; + } + d->utf8_n = 0; + k->kind = CLIOK_UNKNOWN; + k->cp = b; + return 1; +} + +static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) { + d->state = 0; + if (b == '[') { + d->state = 2; + d->csi_n = 0; + return 0; + } + if (b == 'O') { + d->state = 3; + return 0; + } + if (b >= 0x20 && b <= 0x7E) { + k->kind = CLIOK_CHAR; + k->cp = b; + return 1; + } + k->kind = CLIOK_ESC; + k->cp = 0; + return 1; +} + +static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) { + if (d->csi_n < (int) sizeof d->csi) + d->csi[d->csi_n++] = b; + if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' || + b == '>') + return 0; + d->state = 0; + unsigned long num = 0; + int any = 0; + for (int i = 0; i < d->csi_n - 1; i++) { + unsigned char p = d->csi[i]; + if (p < '0' || p > '9') + break; + if (any) + num = num * 10 + (unsigned long)(p - '0'); + else + num = (unsigned long)(p - '0'); + any = 1; + if (num > 0xFFFFu) + break; + } + d->csi_n = 0; + switch (b) { + case 'A': + k->kind = CLIOK_UP; + k->cp = 0; + return 1; + case 'B': + k->kind = CLIOK_DOWN; + k->cp = 0; + return 1; + case 'C': + k->kind = CLIOK_RIGHT; + k->cp = 0; + return 1; + case 'D': + k->kind = CLIOK_LEFT; + k->cp = 0; + return 1; + case 'H': + k->kind = CLIOK_HOME; + k->cp = 0; + return 1; + case 'F': + k->kind = CLIOK_END; + k->cp = 0; + return 1; + case 'Z': + k->kind = CLIOK_BACKTAB; + k->cp = 0; + return 1; + case '~': + switch (num) { + case 1u: + case 7u: + k->kind = CLIOK_HOME; + break; + case 3u: + k->kind = CLIOK_DELETE; + break; + case 4u: + case 8u: + k->kind = CLIOK_END; + break; + case 5u: + k->kind = CLIOK_PAGEUP; + break; + case 6u: + k->kind = CLIOK_PAGEDOWN; + break; + case 200u: + d->pasting = 1; + return 0; + case 201u: + d->pasting = 0; + return 0; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; + case 'u': /* kitty keyboard protocol */ + switch (num) { + case 9u: + k->kind = CLIOK_TAB; + break; + case 12u: + k->kind = CLIOK_BACKSPACE; + break; + case 13u: + k->kind = CLIOK_ENTER; + break; + case 27u: + k->kind = CLIOK_ESC; + break; + case 127u: + k->kind = CLIOK_DELETE; + break; + case 0x2190u: + k->kind = CLIOK_LEFT; + break; + case 0x2191u: + k->kind = CLIOK_UP; + break; + case 0x2192u: + k->kind = CLIOK_RIGHT; + break; + case 0x2193u: + k->kind = CLIOK_DOWN; + break; + default: + k->cp = 0; + return 0; + } + k->cp = 0; + return 1; + case 'R': /* cursor position report: swallow */ + case 'M': + case 'h': + case 'l': + case 'm': + return 0; + default: + k->kind = CLIOK_UNKNOWN; + k->cp = 0; + return 1; + } +} + +static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) { + d->state = 0; + switch (b) { + case 'A': + k->kind = CLIOK_UP; + break; + case 'B': + k->kind = CLIOK_DOWN; + break; + case 'C': + k->kind = CLIOK_RIGHT; + break; + case 'D': + k->kind = CLIOK_LEFT; + break; + case 'H': + k->kind = CLIOK_HOME; + break; + case 'F': + k->kind = CLIOK_END; + break; + default: + k->kind = CLIOK_UNKNOWN; + break; + } + k->cp = 0; + return 1; +} + +static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) { + switch (d->state) { + case 1: + return clio_kd_escape(d, b, k); + case 2: + return clio_kd_csi(d, b, k); + case 3: + return clio_kd_ss3(d, b, k); + default: + return clio_kd_ground(d, b, k); + } +} + +/* Feed raw bytes; emits up to cap decoded keys into out; returns the + * number emitted. */ +static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in, + size_t n, clio_key *out, size_t cap) { + size_t emitted = 0; + for (size_t i = 0; i < n; i++) { + clio_key k; + if (clio_kd_step(d, in[i], &k) && emitted < cap) + out[emitted++] = k; + } + return emitted; +} + +/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */ +static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) { + if (d->state != 1) + return 0; + d->state = 0; + k->kind = CLIOK_ESC; + k->cp = 0; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* Terminal state */ +/* ------------------------------------------------------------------ */ + +#define CLIO_KEYQ 32 + +typedef struct clio_term { + int is_tty; + int color; /* clio_color_policy() result */ + size_t rows; + size_t cols; + int raw; + struct termios saved; + int alt; + int cursor_hidden; + int paste; + int kitty; + clio_keydec dec; + clio_key q[CLIO_KEYQ]; + size_t qh; + size_t qt; + clio_buf last; + int has_last; +} clio_term; + +/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty + * without NO_COLOR, TERM=dumb, or CI. */ +static inline int clio_color_policy(int tty) { + const char *force = getenv("CLICOLOR_FORCE"); + if (force && force[0]) { + if (strcmp(force, "0") == 0) + return 0; + return 1; + } + if (!tty) + return 0; + if (getenv("NO_COLOR")) + return 0; + const char *term = getenv("TERM"); + if (term && strcmp(term, "dumb") == 0) + return 0; + if (getenv("CI")) + return 0; + return 1; +} + +static inline int clio_term_init(clio_term *t) { + memset(t, 0, sizeof *t); + t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0; + t->color = clio_color_policy(t->is_tty); + t->rows = 24; + t->cols = 80; + struct winsize ws; + if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && + ws.ws_row > 0 && ws.ws_col > 0) { + t->rows = (size_t) ws.ws_row; + t->cols = (size_t) ws.ws_col; + } + clio_keydec_init(&t->dec); + return 0; +} + +/* Re-read the window size; invalidates the flushed-frame cache. -1 on + * failure (previous size kept). */ +static inline int clio_term_resize(clio_term *t) { + struct winsize ws; + if (!t->is_tty) + return -1; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0) + return -1; + if (ws.ws_row == 0 || ws.ws_col == 0) + return -1; + t->rows = (size_t) ws.ws_row; + t->cols = (size_t) ws.ws_col; + clio_buf_free(&t->last); + t->has_last = 0; + return 0; +} + +static inline int clio_term_raw_on(clio_term *t) { + if (t->raw || !t->is_tty) + return -1; + if (tcgetattr(STDIN_FILENO, &t->saved) != 0) + return -1; + struct termios raw = t->saved; + raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON)); + raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG)); + raw.c_oflag &= ~((tcflag_t) OPOST); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) + return -1; + t->raw = 1; + return 0; +} + +static inline void clio_term_raw_off(clio_term *t) { + if (!t->raw) + return; + tcsetattr(STDIN_FILENO, TCSANOW, &t->saved); + t->raw = 0; +} + +static inline void clio_term_alt_on(clio_term *t) { + if (t->alt) + return; + esc_falt_screen_on(stdout); + fflush(stdout); + t->alt = 1; + clio_buf_free(&t->last); + t->has_last = 0; +} + +static inline void clio_term_alt_off(clio_term *t) { + if (!t->alt) + return; + esc_falt_screen_off(stdout); + fflush(stdout); + t->alt = 0; + clio_buf_free(&t->last); + t->has_last = 0; +} + +static inline void clio_term_cursor_hide(clio_term *t) { + if (t->cursor_hidden) + return; + esc_fcursor_hide(stdout); + fflush(stdout); + t->cursor_hidden = 1; +} + +static inline void clio_term_cursor_show(clio_term *t) { + if (!t->cursor_hidden) + return; + esc_fcursor_show(stdout); + fflush(stdout); + t->cursor_hidden = 0; +} + +static inline void clio_term_paste_on(clio_term *t) { + if (t->paste) + return; + esc_fbracketed_paste_on(stdout); + fflush(stdout); + t->paste = 1; + t->dec.pasting = 1; +} + +static inline void clio_term_paste_off(clio_term *t) { + if (!t->paste) + return; + esc_fbracketed_paste_off(stdout); + fflush(stdout); + t->paste = 0; + t->dec.pasting = 0; +} + +static inline void clio_term_kitty_on(clio_term *t) { + if (t->kitty) + return; + esc_fkitty_on(stdout); + fflush(stdout); + t->kitty = 1; +} + +static inline void clio_term_kitty_off(clio_term *t) { + if (!t->kitty) + return; + esc_fkitty_off(stdout); + fflush(stdout); + t->kitty = 0; +} + +static inline void clio_term_bell(clio_term *t) { + (void) t; + esc_fbell(stdout); + fflush(stdout); +} + +/* Restore everything clio_term turned on (idempotent). */ +static inline void clio_term_shutdown(clio_term *t) { + if (t->kitty) + clio_term_kitty_off(t); + if (t->paste) + clio_term_paste_off(t); + if (t->cursor_hidden) + clio_term_cursor_show(t); + if (t->alt) + clio_term_alt_off(t); + clio_term_raw_off(t); +} + +static inline void clio_term_free(clio_term *t) { + clio_term_shutdown(t); + clio_buf_free(&t->last); + memset(t, 0, sizeof *t); +} + +/* ------------------------------------------------------------------ */ +/* Output (diff flush) */ +/* ------------------------------------------------------------------ */ + +/* Map a style onto the stream: reset, then attributes, then 256-color + * fg/bg. escape.h emits the codes; clio.h only decides what goes. */ +static inline void clio_fstyle(FILE *f, clio_style st) { + if (!f) + return; + fputs(ESC_RESET, f); + if (st.attr & CLIO_BOLD) + fputs(ESC_BOLD, f); + if (st.attr & CLIO_DIM) + fputs(ESC_DIM, f); + if (st.attr & CLIO_ITALIC) + fputs(ESC_ITALIC, f); + if (st.attr & CLIO_UNDERLINE) + fputs(ESC_UNDERLINE, f); + if (st.attr & CLIO_REVERSE) + fputs(ESC_REVERSE, f); + if (st.attr & CLIO_STRIKE) + fputs(ESC_STRIKETHROUGH, f); + if (st.fg >= 0) + esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF)); + if (st.bg >= 0) + esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF)); +} + +/* Render one row: style runs + text. Blank cells print as spaces. */ +static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) { + clio_style cur = clio_style_default(); + for (size_t c = 0; c < b->cols; c++) { + const clio_cell *cell = &b->cells[row * b->cols + c]; + clio_style st = clio_cell_style(cell); + if (!clio_style_eq(&st, &cur)) { + clio_fstyle(f, st); + cur = st; + } + if (cell->ch == 0) { + fputc(' ', f); + } else if (cell->ch < 0x80) { + fputc((int)(cell->ch & 0xFF), f); + } else { + char tmp[8]; + if (clio_utf8_enc(cell->ch, tmp) > 0) + fputs(tmp, f); + else + fputc(' ', f); + } + } +} + +/* Non-tty fallback: plain text lines, trailing blanks trimmed. */ +static inline void clio_flush_plain(const clio_buf *b) { + size_t cap = b->cols * 4 + 1; + char *row = (char *) malloc(cap); + if (!row) + return; + for (size_t r = 0; r < b->rows; r++) { + size_t n = 0; + for (size_t c = 0; c < b->cols; c++) { + const clio_cell *cell = &b->cells[r * b->cols + c]; + if (cell->ch == 0) { + row[n++] = ' '; + } else if (cell->ch < 0x80) { + row[n++] = (char)(cell->ch & 0xFF); + } else { + int e = clio_utf8_enc(cell->ch, row + n); + if (e > 0) + n += (size_t) e; + else + row[n++] = ' '; + } + } + while (n > 0 && row[n - 1] == ' ') + n--; + fwrite(row, 1, n, stdout); + fputc('\n', stdout); + } + free(row); + fflush(stdout); +} + +/* Blit the canvas to the terminal. TTY: diff against the previous frame, + * clear screen + full repaint on first flush or size change, otherwise + * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */ +static inline void clio_flush(clio_term *t, const clio_buf *b) { + if (!b || !b->cells) + return; + if (!t->is_tty) { + clio_flush_plain(b); + return; + } + int full = 1; + if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols) + full = 0; + if (full) { + clio_buf_free(&t->last); + if (clio_buf_init(&t->last, b->rows, b->cols) != 0) { + t->has_last = 0; + return; + } + t->has_last = 1; + esc_fclear_screen(stdout); + for (size_t r = 0; r < b->rows; r++) { + esc_fcursor_position(stdout, 1, (u32)(r + 1)); + clio_emit_row(stdout, b, r); + } + } else { + for (size_t r = 0; r < b->rows; r++) { + int same = 1; + for (size_t c = 0; c < b->cols; c++) { + if (!clio_cell_eq(&t->last.cells[r * b->cols + c], + &b->cells[r * b->cols + c])) { + same = 0; + break; + } + } + if (same) + continue; + esc_fcursor_position(stdout, 1, (u32)(r + 1)); + clio_emit_row(stdout, b, r); + } + } + memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell)); + fflush(stdout); +} + +/* Move the visible cursor (0-based row/col, clamped to the terminal). */ +static inline void clio_cursor(clio_term *t, size_t row, size_t col) { + if (!t->is_tty) + return; + if (row > t->rows) + row = t->rows; + if (col > t->cols) + col = t->cols; + esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1)); + fflush(stdout); +} + +/* ------------------------------------------------------------------ */ +/* Key input */ +/* ------------------------------------------------------------------ */ + +/* Key source: 1 + *k set on success, 0 on timeout/EOF. */ +typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms); + +static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) { + for (;;) { + if (t->qt != t->qh) { + *out = t->q[t->qh]; + t->qh++; + if (t->qh == CLIO_KEYQ) + t->qh = 0; + return 1; + } + if (timeout_ms == 0) + return clio_keydec_flush(&t->dec, out); + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + pfd.revents = 0; + int pr = poll(&pfd, 1, timeout_ms); + if (pr <= 0) + return clio_keydec_flush(&t->dec, out); + unsigned char in[16]; + ssize_t n = read(STDIN_FILENO, in, sizeof in); + if (n <= 0) + return clio_keydec_flush(&t->dec, out); + clio_key tmp[16]; + size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16); + for (size_t i = 0; i < got; i++) { + if (t->qt == t->qh) + return clio_keydec_flush(&t->dec, out); + size_t slot = t->qt; + if (slot == CLIO_KEYQ) + slot = 0; + if (slot == t->qh) { + t->qh++; + if (t->qh == CLIO_KEYQ) + t->qh = 0; + } /* queue full: drop the oldest */ + t->q[slot] = tmp[i]; + t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1; + } + } +} + +static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) { + clio_term *t = (clio_term *) ud; + return clio_read_key(t, k, timeout_ms); +} + +/* ------------------------------------------------------------------ */ +/* Single-line editor */ +/* ------------------------------------------------------------------ */ + +static inline void *clio_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "clio: out of memory\n"); + abort(); + } + return q; +} + +typedef struct clio_str { + char *s; + size_t len; + size_t cap; +} clio_str; + +static inline void clio_str_init(clio_str *x) { + x->s = NULL; + x->len = 0; + x->cap = 0; +} + +static inline void clio_str_free(clio_str *x) { + free(x->s); + x->s = NULL; + x->len = x->cap = 0; +} + +static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) { + if (x->len + n + 1 > x->cap) { + size_t nc = x->cap ? x->cap : 16; + while (x->len + n + 1 > nc) + nc *= 2; + x->s = (char *) clio_xrealloc(x->s, nc); + x->cap = nc; + } + memmove(x->s + at + n, x->s + at, x->len - at); + memcpy(x->s + at, s, n); + x->len += n; + x->s[x->len] = '\0'; +} + +static inline void clio_str_del(clio_str *x, size_t at, size_t n) { + if (at > x->len) + at = x->len; + if (at + n > x->len) + n = x->len - at; + memmove(x->s + at, x->s + at + n, x->len - at - n); + x->len -= n; + x->s[x->len] = '\0'; +} + +/* Count visible (non-zero-width) code points in the first n bytes. */ +static inline size_t clio_visible_n(const char *s, size_t n) { + size_t c = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + u32 cp; + size_t len = clio_utf8_next_n(p, end, &cp); + if (cp != 0 && clio_cp_width(cp) > 0) + c++; + p += len; + } + return c; +} + +/* Paint the prompt + text (echo, or one '*' per visible code point) into + * the canvas at row; returns the caret column. + * + * The input is ONE visual line: it never wraps. When the caret passes the + * right edge the window slides (bash/readline style) so the caret always + * stays visible, so backspace/left-arrow work across the "wrap". */ +static inline size_t clio_readline_paint(clio_buf *b, size_t row, + const char *prompt, clio_style pstyle, + const clio_str *txt, size_t caret, + clio_style tstyle, int echo) { + clio_buf_clear_region(b, row, 0, row, b->cols - 1); + size_t pw = prompt ? clio_text_width(prompt) : 0; + if (prompt && prompt[0]) + clio_buf_put(b, row, 0, prompt, pstyle, 0); + size_t area = (pw < b->cols) ? b->cols - pw : 0; + if (txt->len == 0 || area == 0) + return pw; + if (echo) { + size_t cw = clio_text_width_n(txt->s, caret); + /* Window left edge (display cells): 0 unless the caret does not + * fit, in which case pin the caret to the last column. */ + size_t L = (cw + 1 > area) ? cw - (area - 1) : 0; + /* Largest char boundary <= L (a wide char straddling L is kept + * whole, window starts at it). */ + size_t off = 0, start = 0; + const char *p = txt->s; + const char *end = txt->s + txt->len; + while (p < end) { + u32 cp; + size_t n = clio_utf8_next_n(p, end, &cp); + size_t w = (cp != 0) ? clio_cp_width(cp) : 0; + if (off + w > L) { + start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */ + break; + } + off += w; + p += n; + } + clio_buf_put(b, row, pw, txt->s + start, tstyle, 0); + size_t c = pw + (cw - off); + if (c >= b->cols) /* wide char straddled the left edge */ + c = b->cols - 1; + return c; + } + size_t total = clio_visible_n(txt->s, txt->len); + size_t cw = clio_visible_n(txt->s, caret); + size_t first = (cw + 1 > area) ? cw - (area - 1) : 0; + for (size_t i = first; i < total; i++) + clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0); + return pw + (cw - first); +} + +/* Single-line editor. Renders into ONE canvas row (no wrapping; long + * input scrolls to keep the caret visible); reads keys from nk (NULL + * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab, + * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC + * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints, + * Enter submits. Returns the input (caller frees) or NULL. */ +static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row, + const char *prompt, clio_style pstyle, + clio_style tstyle, int echo, + clio_next_key nk, void *ud, int timeout_ms) { + if (!nk) { + nk = clio_next_key_stdin; + ud = t; + } + clio_str txt; + clio_str_init(&txt); + size_t caret = 0; + + size_t col = + clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo); + if (t->is_tty) + clio_cursor(t, row, col); + + int quit = 0; + for (;;) { + clio_key k; + if (!nk(ud, &k, timeout_ms)) + break; /* timeout / EOF / no key */ + int done = 0; + switch (k.kind) { + case CLIOK_CHAR: { + char tmp[8]; + int e = clio_utf8_enc(k.cp, tmp); + if (e > 0) { + clio_str_ins(&txt, caret, tmp, (size_t) e); + caret += (size_t) e; + } + break; + } + case CLIOK_TAB: + clio_str_ins(&txt, caret, "\t", 1); + caret++; + break; + case CLIOK_ENTER: + done = 1; + break; + case CLIOK_BACKSPACE: + if (caret > 0) { + size_t s = clio_char_before(txt.s, caret); + clio_str_del(&txt, s, caret - s); + caret = s; + } + break; + case CLIOK_DELETE: + if (caret < txt.len) + clio_str_del(&txt, caret, + clio_char_end(txt.s, txt.len, caret) - caret); + break; + case CLIOK_LEFT: + if (caret > 0) + caret = clio_char_before(txt.s, caret); + break; + case CLIOK_RIGHT: + if (caret < txt.len) + caret = clio_char_end(txt.s, txt.len, caret); + break; + case CLIOK_HOME: + case CLIOK_CTRL_A: + caret = 0; + break; + case CLIOK_END: + case CLIOK_CTRL_E: + caret = txt.len; + break; + case CLIOK_CTRL_U: + clio_str_del(&txt, 0, caret); + caret = 0; + break; + case CLIOK_CTRL_K: + clio_str_del(&txt, caret, txt.len - caret); + break; + case CLIOK_CTRL_W: { + size_t s = clio_word_back(txt.s, caret); + clio_str_del(&txt, s, caret - s); + caret = s; + break; + } + case CLIOK_CTRL_C: + quit = 1; + break; + case CLIOK_CTRL_D: + if (txt.len == 0) + done = 1; + else if (caret < txt.len) + clio_str_del(&txt, caret, + clio_char_end(txt.s, txt.len, caret) - caret); + break; + case CLIOK_CTRL_L: + if (t->is_tty) { + esc_fclear_screen(stdout); + esc_fcursor_position(stdout, 1, 1); + fflush(stdout); + } + break; + case CLIOK_ESC: + quit = 1; + break; + default: + break; + } + col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo); + if (t->is_tty) + clio_cursor(t, row, col); + if (done || quit) + break; + } + + if (quit || txt.len == 0) { + clio_str_free(&txt); + return NULL; /* cancelled, or submitted empty */ + } + return txt.s; /* caller frees */ +} + +#ifdef __cplusplus +} +#endif + +#endif /* CLIO_H */ diff --git a/include/dotagents.h b/include/dotagents.h new file mode 100644 index 0000000..659e156 --- /dev/null +++ b/include/dotagents.h @@ -0,0 +1,1282 @@ +/* dotagents.h — portable, header-only implementation of the .agents protocol. + * + * The .agents protocol (dotagentsprotocol.com) is an open directory + * convention for AI agent configuration: one `.agents/` directory holds + * MCP server config, agent guidelines (AGENTS.md-compatible), skills, + * sub-agent profiles, repeat tasks, persistent memories, and model presets — + * in plain, version-controllable files. + * + * This library is the read-only side: it parses and resolves two layers — + * global `~/.agents/` and workspace `./.agents/` — with workspace winning on + * conflicts. It does not write. + * + * Directory schema (per layer): + * agents.md instructions (AGENTS.md-compatible) + * system-prompt.md system prompt + * mcp.json MCP server configuration + * models.json model presets & provider keys + * speakmcp-settings.json general settings + * layouts/ui.json UI/layout prefs + * skills/<id>/skill.md skill definition + * agents/<id>/agent.md sub-agent profile (+ optional config.json) + * tasks/<id>/task.md repeat task definition + * memories/ persistent memory entries (markdown files) + * .backups/ ignored (write-side artifact) + * + * Merge rules (final = global + workspace, workspace wins): + * - agents.md / system-prompt.md: whole-file replacement (workspace wins) + * - models.json: `default` replaced; `presets` merged by preset name + * (workspace wins per name) + * - mcp.json: `mcpServers` merged by server name (workspace wins per name) + * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level + * key (workspace wins per key) + * - skills / agents / tasks / memories: merged by id (workspace wins per id) + * - unknown files and malformed files: warned about, skipped (never fatal) + * + * Conformance (read-only): + * [x] agents.md, system-prompt.md, mcp.json, models.json + * [x] speakmcp-settings.json, layouts/ui.json + * [x] skills/, agents/ (+ config.json), tasks/, memories/ + * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int + * [x] task `profileId`, agent `connection-type` + * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...) + * [x] deterministic id-sorted output for stable diffs + * [ ] write side (atomic writes, .backups/ rotation) — out of scope + * [ ] .dotagents bundles / Hub — out of scope + * + * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the + * same directory (or on the include path). Zero dependencies beyond + * frontmatter.h and libc. All functions are static inline; safe to include + * in any number of translation units. + * + * API: + * dotagents_load(out) load ~/.agents + ./.agents (cwd) + * dotagents_load_from(out, ws, global) load two explicit layer dirs + * dotagents_free(out) free everything + * dotagents_project_sections(d, cap) guidelines + skill index + memory note + * dotagents_compose_preamble(d, base, cap) base + sections + * dotagents_find_skill(d, id) skill or NULL + * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL + * dotagents_mcp_config(d, name) raw per-server JSON or NULL + * models_file_get(models, name) preset or NULL + * models_file_free(models) free a models_file + * memory_importance_rank(m) 0 high, 1 medium, 2 else + * parse_frontmatter / doc_get / doc_free / fm_list_items ... + * from frontmatter.h (re-exported) + * + * Memory: all strings are owned by the `dotagents` struct until + * dotagents_free. Load never fails: missing layers and malformed files are + * recorded in `out->warnings` (or silently skipped when absent). + */ +#ifndef DOTAGENTS_H +#define DOTAGENTS_H + +#include "frontmatter.h" + +#include <dirent.h> +#include <strings.h> +#include <sys/stat.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LAYER_GLOBAL, + LAYER_WORKSPACE, +} layer; + +typedef struct { + char *id; + char *name; + char *description; + bool enabled; + char *body; + layer layer; +} skill; + +typedef struct { + char *id; + char *name; + char *description; + char *role; + char *connection_type; /* `connection-type` frontmatter, or NULL */ + bool enabled; + char *body; + char *config; /* raw JSON text, or NULL */ + layer layer; +} sub_agent; + +typedef struct { + char *id; + char *name; + char *profile_id; /* `profileId` frontmatter, or NULL */ + unsigned long interval_minutes; + bool has_interval; + bool enabled; + bool run_on_startup; + char *body; + layer layer; +} task_entry; + +typedef struct { + char *id; + char *title; + char *importance; + char **tags; + size_t tag_count; + char *summary; + char *body; + layer layer; +} memory; + +typedef struct { + char *name; + char *provider; + char *model; + char *base_url; + char *api_key; + unsigned long max_turns; + bool has_max_turns; + unsigned long context_limit; + bool has_context_limit; +} model_preset; + +typedef struct { + char *default_name; + model_preset *presets; + size_t preset_count; +} models_file; + +typedef struct { + layer *found_layers; + char **found_paths; + size_t found_count; + fm_strlist warnings; + char *system_prompt; + char *agents_md; + char *settings_json; /* merged speakmcp-settings.json, or NULL */ + char *layouts_json; /* merged layouts/ui.json, or NULL */ + fm_json *settings; /* accumulated merge tree (transient) */ + fm_json *layouts; /* accumulated merge tree (transient) */ + models_file models; + bool has_models; + char **mcp_names; + char **mcp_configs; /* raw JSON per server */ + size_t mcp_count; + skill *skills; + size_t skill_count; + sub_agent *sub_agents; + size_t sub_agent_count; + task_entry *tasks; + size_t task_count; + memory *memories; + size_t memory_count; +} dotagents; + +/* qsort comparators (replace the old O(n^2) inline sorts) */ +typedef struct { char *name; char *path; } da_dirent; +static int da_cmp_dirent(const void *a, const void *b) { + return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name); +} +static int da_cmp_skill_id(const void *a, const void *b) { + return strcmp(((const skill *)a)->id, ((const skill *)b)->id); +} +static int da_cmp_subagent_id(const void *a, const void *b) { + return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id); +} +static int da_cmp_task_id(const void *a, const void *b) { + return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id); +} +static int da_cmp_memory_id(const void *a, const void *b) { + return strcmp(((const memory *)a)->id, ((const memory *)b)->id); +} +static int da_cmp_cstr(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* ------------------------------------------------------------------ */ +/* Layer helpers */ +/* ------------------------------------------------------------------ */ + +static inline const char *layer_short(layer l) { + return l == LAYER_GLOBAL ? "global" : "workspace"; +} + +static inline const char *layer_display(layer l) { + return l == LAYER_GLOBAL ? "~/.agents" : ".agents"; +} + +/* ------------------------------------------------------------------ */ +/* File I/O */ +/* ------------------------------------------------------------------ */ + +static inline bool da_is_file(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISREG(st.st_mode); +} + +static inline bool da_is_dir(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISDIR(st.st_mode); +} + +/* Read a whole file. 0 on success, -1 on any failure. */ +static inline int da_read_file(const char *path, char **out, size_t *out_len) { + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + fm_buf b; + fm_buf_init(&b); + char chunk[8192]; + size_t r; + while ((r = fread(chunk, 1, sizeof chunk, f)) > 0) + fm_buf_putn(&b, chunk, r); + int fe = ferror(f); + fclose(f); + if (fe) { + fm_buf_free(&b); + return -1; + } + size_t total = b.len; + if (out) + *out = fm_buf_steal(&b); + if (out_len) + *out_len = total; + return 0; +} + +static inline bool da_dir_has_entry(const char *dir) { + DIR *d = opendir(dir); + if (!d) + return false; + bool has = readdir(d) != NULL; + closedir(d); + return has; +} + +/* Find `{stem}.md` case-insensitively directly inside dir. */ +static inline char *da_find_md(const char *dir, const char *stem) { + char *want = fm_strfmt("%s.md", stem); + for (char *p = want; *p; p++) + *p = (char)tolower((unsigned char)*p); + DIR *d = opendir(dir); + if (!d) { + free(want); + return NULL; + } + char *result = NULL; + struct dirent *e; + while ((e = readdir(d))) { + if (strcasecmp(e->d_name, want) == 0) { + result = fm_strfmt("%s/%s", dir, e->d_name); + break; + } + } + closedir(d); + free(want); + return result; +} + +/* Sorted subdirectories of `dir`: returns name+path arrays. */ +static inline void da_sub_dirs(const char *dir, char ***out_names, + char ***out_paths, size_t *out_count) { + *out_count = 0; + DIR *d = opendir(dir); + if (!d) + return; + size_t cap = 8, n = 0; + char **names = (char **)fm_xmalloc(cap * sizeof(char *)); + char **paths = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) + continue; + char *path = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_dir(path)) { + free(path); + continue; + } + if (n + 1 > cap) { + cap *= 2; + names = (char **)fm_xrealloc(names, cap * sizeof(char *)); + paths = (char **)fm_xrealloc(paths, cap * sizeof(char *)); + } + names[n] = fm_strdup(e->d_name); + paths[n] = path; + n++; + } + closedir(d); + /* sort by name (keep the parallel paths array in lockstep) */ + if (n > 1) { + da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents); + for (size_t i = 0; i < n; i++) { + ents[i].name = names[i]; + ents[i].path = paths[i]; + } + qsort(ents, n, sizeof *ents, da_cmp_dirent); + for (size_t i = 0; i < n; i++) { + names[i] = ents[i].name; + paths[i] = ents[i].path; + } + free(ents); + } + *out_names = names; + *out_paths = paths; + *out_count = n; +} + +/* Does this root hold any recognizable protocol content? */ +static inline bool da_has_protocol_content(const char *root) { + const char *stems[2] = {"agents", "system-prompt"}; + for (size_t i = 0; i < 2; i++) { + char *md = da_find_md(root, stems[i]); + if (md) { + free(md); + return true; + } + } + const char *files[4] = {"models.json", "mcp.json", + "speakmcp-settings.json", "layouts/ui.json"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, files[i]); + bool has = da_is_file(p); + free(p); + if (has) + return true; + } + const char *subs[4] = {"skills", "agents", "tasks", "memories"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, subs[i]); + bool has = da_is_dir(p) && da_dir_has_entry(p); + free(p); + if (has) + return true; + } + return false; +} + +/* ------------------------------------------------------------------ */ +/* Accumulator helpers */ +/* ------------------------------------------------------------------ */ + +static inline void da_warn(dotagents *out, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *msg = fm_vsnprintf(fmt, ap); + va_end(ap); + fm_sl_push(&out->warnings, msg); +} + +static inline char *da_read_optional(const char *path, layer layer, + dotagents *out) { + char *raw = NULL; + size_t len = 0; + if (da_read_file(path, &raw, &len) != 0) { + struct stat st; + if (stat(path, &st) == 0) { + /* exists but unreadable */ + const char *base = strrchr(path, '/'); + da_warn(out, "%s: %s: read error", layer_display(layer), + base ? base + 1 : path); + } + free(raw); + return NULL; + } + /* whitespace-only: treat as absent */ + size_t i = 0; + while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' || + raw[i] == '\r')) + i++; + if (i == len) { + free(raw); + return NULL; + } + return raw; +} + +/* ------------------------------------------------------------------ */ +/* Ingest */ +/* ------------------------------------------------------------------ */ + +static inline void da_ingest_models_json(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/models.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + fm_json_free(o); + return; + } + /* unknown field check (deny_unknown_fields) */ + bool ok = true; + for (size_t i = 0; i < o->count; i++) { + const char *k = o->items[i] ? o->items[i]->key : NULL; + if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) { + ok = false; + break; + } + } + if (ok) { + const fm_json *def = fm_json_get(o, "default"); + if (def && def->type == FMJ_STRING) { + free(out->models.default_name); + out->models.default_name = fm_strdup(def->str); + } + const fm_json *pres = fm_json_get(o, "presets"); + if (pres && pres->type == FMJ_OBJECT) { + for (size_t pi = 0; pi < pres->count; pi++) { + const fm_json *pv = pres->items[pi]; + if (!pv || pv->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: preset `%s` is not an " + "object; skipped", + layer_short(layer), pv && pv->key ? pv->key : "?"); + continue; + } + const fm_json *bad = NULL; + for (size_t fi = 0; fi < pv->count; fi++) { + const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL; + if (!fk || + (strcmp(fk, "provider") != 0 && + strcmp(fk, "model") != 0 && + strcmp(fk, "base_url") != 0 && + strcmp(fk, "api_key") != 0 && + strcmp(fk, "max_turns") != 0 && + strcmp(fk, "context_limit") != 0)) { + bad = pv->items[fi]; + break; + } + } + if (bad) { + da_warn(out, "%s: models.json: preset `%s` has unknown " + "field `%s`; skipped", + layer_short(layer), pv->key ? pv->key : "?", + bad->key ? bad->key : "?"); + continue; + } + model_preset mp; + memset(&mp, 0, sizeof mp); + mp.name = fm_strdup(pv->key ? pv->key : ""); + const fm_json *s; + if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING) + mp.provider = fm_strdup(s->str); + if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING) + mp.model = fm_strdup(s->str); + if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING) + mp.base_url = fm_strdup(s->str); + if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING) + mp.api_key = fm_strdup(s->str); + if ((s = fm_json_get(pv, "max_turns")) && + s->type == FMJ_NUMBER) { + mp.max_turns = (unsigned long)s->num; + mp.has_max_turns = true; + } + if ((s = fm_json_get(pv, "context_limit")) && + s->type == FMJ_NUMBER) { + mp.context_limit = (unsigned long)s->num; + mp.has_context_limit = true; + } + /* replace by name or append */ + bool replaced = false; + for (size_t i = 0; i < out->models.preset_count; i++) { + if (strcmp(out->models.presets[i].name, pv->key) == 0) { + free(out->models.presets[i].name); + free(out->models.presets[i].provider); + free(out->models.presets[i].model); + free(out->models.presets[i].base_url); + free(out->models.presets[i].api_key); + out->models.presets[i] = mp; + replaced = true; + break; + } + } + if (!replaced) { + out->models.presets = (model_preset *)fm_xrealloc( + out->models.presets, + (out->models.preset_count + 1) * + sizeof(model_preset)); + out->models.presets[out->models.preset_count++] = mp; + } + out->has_models = true; + } + } + } else { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + } + fm_json_free(o); +} + +static inline void da_ingest_mcp(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/mcp.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o) { + da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer)); + return; + } + const fm_json *servers = fm_json_get(o, "mcpServers"); + if (!servers || servers->type != FMJ_OBJECT) { + da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + for (size_t i = 0; i < servers->count; i++) { + const fm_json *sv = servers->items[i]; + if (!sv) + continue; + char *cfg = fm_json_serialize(sv); + bool replaced = false; + for (size_t j = 0; j < out->mcp_count; j++) { + if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) { + free(out->mcp_configs[j]); + out->mcp_configs[j] = cfg; + replaced = true; + break; + } + } + if (!replaced) { + out->mcp_names = + (char **)fm_xrealloc(out->mcp_names, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_configs = + (char **)fm_xrealloc(out->mcp_configs, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_names[out->mcp_count] = + fm_strdup(sv->key ? sv->key : ""); + out->mcp_configs[out->mcp_count] = cfg; + out->mcp_count++; + } + } + fm_json_free(o); +} + +static inline void da_ingest_settings(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/speakmcp-settings.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->settings = fm_json_merge_owned(out->settings, o); +} + +static inline void da_ingest_layouts(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/layouts/ui.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->layouts = fm_json_merge_owned(out->layouts, o); +} + +static inline void da_ingest_skills(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/skills", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "skill"); + if (!md) { + da_warn(out, "%s: skills/%s: missing skill.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: skills/%s: skill.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + skill s; + s.id = fm_strdup(id); + s.name = fm_strdup(name); + s.description = fm_strdup(desc); + s.enabled = enabled; + s.body = fm_strdup(d.body); + s.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->skill_count; j++) { + if (strcmp(out->skills[j].id, s.id) == 0) { + free(out->skills[j].id); + free(out->skills[j].name); + free(out->skills[j].description); + free(out->skills[j].body); + out->skills[j] = s; + replaced = true; + break; + } + } + if (!replaced) { + out->skills = (skill *)fm_xrealloc( + out->skills, (out->skill_count + 1) * sizeof(skill)); + out->skills[out->skill_count++] = s; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + /* keep sorted by id */ + if (out->skill_count > 1) + qsort(out->skills, out->skill_count, sizeof *out->skills, + da_cmp_skill_id); +} + +static inline void da_ingest_sub_agents(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/agents", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "agent"); + if (!md) { + da_warn(out, "%s: agents/%s: missing agent.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: agents/%s: agent.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + /* optional config.json next to agent.md */ + char *config = NULL; + char *cp = fm_strfmt("%s/config.json", paths[i]); + char *craw = NULL; + if (da_read_file(cp, &craw, NULL) == 0) { + size_t cl = strlen(craw); + size_t ci = 0; + while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' || + craw[ci] == '\n' || craw[ci] == '\r')) + ci++; + if (ci < cl) { + fm_json *cv = fm_json_parse(craw); + if (cv) { + config = fm_json_serialize(cv); + fm_json_free(cv); + } else { + da_warn(out, "%s: agents/%s/config.json: invalid JSON; " + "skipped", + layer_short(layer), names[i]); + } + } + free(craw); + } + free(cp); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + char *role = "delegation-target"; + if ((v = doc_get(&d, "role")) && v->kind == FM_STR) + role = v->s; + char *ctype = NULL; + if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR) + ctype = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + sub_agent a; + a.id = fm_strdup(id); + a.name = fm_strdup(name); + a.description = fm_strdup(desc); + a.role = fm_strdup(role); + a.connection_type = ctype ? fm_strdup(ctype) : NULL; + a.enabled = enabled; + a.body = fm_strdup(d.body); + a.config = config; + a.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->sub_agent_count; j++) { + if (strcmp(out->sub_agents[j].id, a.id) == 0) { + free(out->sub_agents[j].id); + free(out->sub_agents[j].name); + free(out->sub_agents[j].description); + free(out->sub_agents[j].role); + free(out->sub_agents[j].connection_type); + free(out->sub_agents[j].body); + free(out->sub_agents[j].config); + out->sub_agents[j] = a; + replaced = true; + break; + } + } + if (!replaced) { + out->sub_agents = + (sub_agent *)fm_xrealloc(out->sub_agents, + (out->sub_agent_count + 1) * + sizeof(sub_agent)); + out->sub_agents[out->sub_agent_count++] = a; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->sub_agent_count > 1) + qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents, + da_cmp_subagent_id); +} + +static inline void da_ingest_tasks(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/tasks", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "task"); + if (!md) { + da_warn(out, "%s: tasks/%s: missing task.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: tasks/%s: task.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *pid = NULL; + if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s && + *v->s) + pid = v->s; + + task_entry t; + memset(&t, 0, sizeof t); + t.id = fm_strdup(id); + t.name = fm_strdup(name); + t.profile_id = pid ? fm_strdup(pid) : NULL; + if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT && + v->i >= 0) { + t.interval_minutes = (unsigned long)v->i; + t.has_interval = true; + } + t.enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + t.enabled = v->b; + t.run_on_startup = false; + if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL) + t.run_on_startup = v->b; + t.body = fm_strdup(d.body); + t.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->task_count; j++) { + if (strcmp(out->tasks[j].id, t.id) == 0) { + free(out->tasks[j].id); + free(out->tasks[j].name); + free(out->tasks[j].profile_id); + free(out->tasks[j].body); + out->tasks[j] = t; + replaced = true; + break; + } + } + if (!replaced) { + out->tasks = + (task_entry *)fm_xrealloc(out->tasks, + (out->task_count + 1) * + sizeof(task_entry)); + out->tasks[out->task_count++] = t; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->task_count > 1) + qsort(out->tasks, out->task_count, sizeof *out->tasks, + da_cmp_task_id); +} + +static inline void da_ingest_memories(const char *root, layer layer, + dotagents *out) { + char *dir = fm_strfmt("%s/memories", root); + DIR *d = opendir(dir); + if (!d) { + free(dir); + return; + } + /* collect .md files */ + size_t cap = 8, n = 0; + char **files = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + char *p = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_file(p)) { + free(p); + continue; + } + const char *dot = strrchr(e->d_name, '.'); + if (!dot || strcasecmp(dot, ".md") != 0) { + free(p); + continue; + } + if (n + 1 > cap) { + cap *= 2; + files = (char **)fm_xrealloc(files, cap * sizeof(char *)); + } + files[n++] = p; + } + closedir(d); + if (n > 1) + qsort(files, n, sizeof *files, da_cmp_cstr); + for (size_t i = 0; i < n; i++) { + const char *base = strrchr(files[i], '/'); + base = base ? base + 1 : files[i]; + const char *dot = strrchr(base, '.'); + char *stem = fm_strndup( + base, dot ? (size_t)(dot - base) : strlen(base)); + char *text = NULL; + if (da_read_file(files[i], &text, NULL) != 0) { + da_warn(out, "%s: memories/%s.md: unreadable; skipped", + layer_short(layer), stem); + free(stem); + continue; + } + doc d2; + parse_frontmatter(text, &d2); + free(text); + + char *id = stem; + const fm_value *v; + if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *title = stem; + if ((v = doc_get(&d2, "title")) && v->kind == FM_STR) + title = v->s; + char *importance = "medium"; + if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR) + importance = v->s; + char *summary = ""; + if ((v = doc_get(&d2, "content")) && v->kind == FM_STR) + summary = v->s; + + memory m; + memset(&m, 0, sizeof m); + m.id = fm_strdup(id); + m.title = fm_strdup(title); + m.importance = fm_strdup(importance); + m.summary = fm_strdup(summary); + m.body = fm_strdup(d2.body); + m.layer = layer; + if ((v = doc_get(&d2, "tags"))) { + fm_strlist items; + fm_list_items(v, &items); + m.tags = (char **)fm_xmalloc( + (items.len ? items.len : 1) * sizeof(char *)); + for (size_t k = 0; k < items.len; k++) + m.tags[k] = fm_strdup(items.items[k]); + m.tag_count = items.len; + fm_sl_free(&items); + } + + bool replaced = false; + for (size_t j = 0; j < out->memory_count; j++) { + if (strcmp(out->memories[j].id, m.id) == 0) { + free(out->memories[j].id); + free(out->memories[j].title); + free(out->memories[j].importance); + free(out->memories[j].summary); + free(out->memories[j].body); + for (size_t k = 0; k < out->memories[j].tag_count; k++) + free(out->memories[j].tags[k]); + free(out->memories[j].tags); + out->memories[j] = m; + replaced = true; + break; + } + } + if (!replaced) { + out->memories = + (memory *)fm_xrealloc(out->memories, + (out->memory_count + 1) * + sizeof(memory)); + out->memories[out->memory_count++] = m; + } + doc_free(&d2); + free(stem); + } + for (size_t i = 0; i < n; i++) + free(files[i]); + free(files); + free(dir); + if (out->memory_count > 1) + qsort(out->memories, out->memory_count, sizeof *out->memories, + da_cmp_memory_id); +} + +static inline void da_ingest(const char *root, layer layer, dotagents *out) { + char *agents_md = da_find_md(root, "agents"); + if (agents_md) { + char *text = da_read_optional(agents_md, layer, out); + if (text) { + free(out->agents_md); + out->agents_md = text; + } + free(agents_md); + } + char *sp = da_find_md(root, "system-prompt"); + if (sp) { + char *text = da_read_optional(sp, layer, out); + if (text) { + free(out->system_prompt); + out->system_prompt = text; + } + free(sp); + } + da_ingest_models_json(root, layer, out); + da_ingest_mcp(root, layer, out); + da_ingest_settings(root, layer, out); + da_ingest_layouts(root, layer, out); + da_ingest_skills(root, layer, out); + da_ingest_sub_agents(root, layer, out); + da_ingest_tasks(root, layer, out); + da_ingest_memories(root, layer, out); +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +static inline void dotagents_free(dotagents *d) { + free(d->found_layers); + for (size_t i = 0; i < d->found_count; i++) + free(d->found_paths[i]); + free(d->found_paths); + fm_sl_free(&d->warnings); + free(d->system_prompt); + free(d->agents_md); + free(d->settings_json); + free(d->layouts_json); + fm_json_free(d->settings); + fm_json_free(d->layouts); + free(d->models.default_name); + for (size_t i = 0; i < d->models.preset_count; i++) { + free(d->models.presets[i].name); + free(d->models.presets[i].provider); + free(d->models.presets[i].model); + free(d->models.presets[i].base_url); + free(d->models.presets[i].api_key); + } + free(d->models.presets); + for (size_t i = 0; i < d->mcp_count; i++) { + free(d->mcp_names[i]); + free(d->mcp_configs[i]); + } + free(d->mcp_names); + free(d->mcp_configs); + for (size_t i = 0; i < d->skill_count; i++) { + free(d->skills[i].id); + free(d->skills[i].name); + free(d->skills[i].description); + free(d->skills[i].body); + } + free(d->skills); + for (size_t i = 0; i < d->sub_agent_count; i++) { + free(d->sub_agents[i].id); + free(d->sub_agents[i].name); + free(d->sub_agents[i].description); + free(d->sub_agents[i].role); + free(d->sub_agents[i].connection_type); + free(d->sub_agents[i].body); + free(d->sub_agents[i].config); + } + free(d->sub_agents); + for (size_t i = 0; i < d->task_count; i++) { + free(d->tasks[i].id); + free(d->tasks[i].name); + free(d->tasks[i].profile_id); + free(d->tasks[i].body); + } + free(d->tasks); + for (size_t i = 0; i < d->memory_count; i++) { + free(d->memories[i].id); + free(d->memories[i].title); + free(d->memories[i].importance); + for (size_t k = 0; k < d->memories[i].tag_count; k++) + free(d->memories[i].tags[k]); + free(d->memories[i].tags); + free(d->memories[i].summary); + free(d->memories[i].body); + } + free(d->memories); +} + +static inline void dotagents_load_from(dotagents *out, const char *workspace, + const char *global) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + if (global && da_is_dir(global) && da_has_protocol_content(global)) { + da_ingest(global, LAYER_GLOBAL, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_GLOBAL; + out->found_paths[out->found_count] = fm_strdup(global); + out->found_count++; + } + if (da_is_dir(workspace) && da_has_protocol_content(workspace)) { + da_ingest(workspace, LAYER_WORKSPACE, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_WORKSPACE; + out->found_paths[out->found_count] = fm_strdup(workspace); + out->found_count++; + } + /* materialize the transient merge trees as stable JSON text */ + if (out->settings) { + out->settings_json = fm_json_serialize(out->settings); + fm_json_free(out->settings); + out->settings = NULL; + } + if (out->layouts) { + out->layouts_json = fm_json_serialize(out->layouts); + fm_json_free(out->layouts); + out->layouts = NULL; + } +} + +static inline void dotagents_load(dotagents *out) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + const char *home = getenv("HOME"); + char *global = NULL; + if (home) + global = fm_strfmt("%s/.agents", home); + dotagents_load_from(out, ".agents", global); + free(global); +} + +/* Sections appended to the preamble: guidelines + skill index + memories. */ +static inline char *dotagents_project_sections(const dotagents *d, + unsigned long cap) { + if (cap < 1) + cap = 1; + fm_buf out; + fm_buf_init(&out); + if (d->agents_md && d->agents_md[0] && + d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) { + fm_buf_push(&out, '\n'); + /* trim end */ + size_t l = strlen(d->agents_md); + while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' || + d->agents_md[l - 1] == ' ')) + l--; + fm_buf_putn(&out, d->agents_md, l); + fm_buf_push(&out, '\n'); + } + /* enabled skills */ + size_t enabled_count = 0; + for (size_t i = 0; i < d->skill_count; i++) + if (d->skills[i].enabled) + enabled_count++; + if (enabled_count > 0) { + fm_buf_put(&out, "\n# Skills\n\n"); + fm_buf_put( + &out, + "Skills available for this project. Load a skill's full " + "instructions with the `use_skill` tool (pass its id):\n"); + size_t listed = 0; + for (size_t i = 0; i < d->skill_count && listed < cap; i++) { + if (!d->skills[i].enabled) + continue; + if (d->skills[i].description[0]) + fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id, + d->skills[i].description); + else + fm_buf_fmt(&out, "- %s\n", d->skills[i].id); + listed++; + } + if (enabled_count > cap) + fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap); + } + if (d->memory_count > 0) { + fm_buf_fmt( + &out, + "\n# Memories\n\n%lu persistent memory entries are available " + "across sessions. Retrieve them with the `search_memories` tool " + "(keyword or tag query).\n", + d->memory_count); + } + return fm_buf_steal(&out); +} + +static inline char *dotagents_compose_preamble(const dotagents *d, + const char *base, + unsigned long cap) { + char *sections = dotagents_project_sections(d, cap); + fm_buf b; + fm_buf_init(&b); + fm_buf_put(&b, base); + fm_buf_put(&b, sections); + free(sections); + return fm_buf_steal(&b); +} + +static inline const sub_agent *dotagents_enabled_sub_agent( + const dotagents *d, const char *id) { + for (size_t i = 0; i < d->sub_agent_count; i++) + if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled) + return &d->sub_agents[i]; + return NULL; +} + +static inline const skill *dotagents_find_skill(const dotagents *d, + const char *id) { + for (size_t i = 0; i < d->skill_count; i++) + if (strcmp(d->skills[i].id, id) == 0) + return &d->skills[i]; + return NULL; +} + +static inline const char *dotagents_mcp_config(const dotagents *d, + const char *name) { + for (size_t i = 0; i < d->mcp_count; i++) + if (strcmp(d->mcp_names[i], name) == 0) + return d->mcp_configs[i]; + return NULL; +} + +static inline const model_preset *models_file_get(const models_file *m, + const char *name) { + for (size_t i = 0; i < m->preset_count; i++) + if (strcmp(m->presets[i].name, name) == 0) + return &m->presets[i]; + return NULL; +} + +static inline void models_file_free(models_file *m) { + free(m->default_name); + for (size_t i = 0; i < m->preset_count; i++) { + free(m->presets[i].name); + free(m->presets[i].provider); + free(m->presets[i].model); + free(m->presets[i].base_url); + free(m->presets[i].api_key); + } + free(m->presets); + m->presets = NULL; + m->preset_count = 0; +} + +static inline int memory_importance_rank(const memory *m) { + fm_buf l; + fm_buf_init(&l); + for (const char *p = m->importance; *p; p++) + fm_buf_push(&l, (char)tolower((unsigned char)*p)); + l.data[l.len] = '\0'; + int r = 2; + if (strcmp(l.data, "high") == 0) + r = 0; + else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0) + r = 1; + fm_buf_free(&l); + return r; +} + +#ifdef __cplusplus +} +#endif + +#endif /* DOTAGENTS_H */ diff --git a/include/escape.h b/include/escape.h new file mode 100755 index 0000000..af616b3 --- /dev/null +++ b/include/escape.h @@ -0,0 +1,272 @@ +// escape.h - ESC escape code macros and functions +// +// Thread safety: Safe for concurrent use (no shared state). The macros +// produce string literals; the functions write to the provided FILE*. +// Portability: ANSI escape codes are not supported on Windows Console API +// (use Windows 10+ VT mode or a translation layer). + +#ifndef ESCAPE_H +#define ESCAPE_H + +#include "type.h" +#include <stdio.h> + +#ifdef __cplusplus +extern "C" { +#endif + +#define CSI "\x1B[" + +#define ESC_CURSOR_UP(n) CSI #n "A" +void esc_cursor_up(u32 n); +void esc_fcursor_up(FILE *, u32 n); + +#define ESC_CURSOR_DOWN(n) CSI #n "B" +void esc_cursor_down(u32 n); +void esc_fcursor_down(FILE *, u32 n); + +#define ESC_CURSOR_LEFT(n) CSI #n "D" +void esc_cursor_left(u32 n); +void esc_fcursor_left(FILE *, u32 n); + +#define ESC_CURSOR_RIGHT(n) CSI #n "C" +void esc_cursor_right(u32 n); +void esc_fcursor_right(FILE *, u32 n); + +#define ESC_CURSOR_LINE_NEXT(n) CSI #n "E" +void esc_cursor_line_next(u32 n); +void esc_fcursor_line_next(FILE *, u32 n); + +#define ESC_CURSOR_PREVIOUS(n) CSI #n "F" +void esc_cursor_previous(u32 n); +void esc_fcursor_previous(FILE *, u32 n); + +#define ESC_CURSOR_POSITION(col, row) CSI #row ";" #col "H" +void esc_cursor_position(u32 col, u32 row); +void esc_fcursor_position(FILE *, u32 col, u32 row); + +#define ESC_CURSOR_COLUMN_HOME CSI "G" + +#define ESC_CLEAR_SCREEN CSI "2J" +void esc_clear_screen(void); +void esc_fclear_screen(FILE *); + +#define ESC_CLEAR_SCREEN_START CSI "1J" +void esc_clear_screen_start(void); +void esc_fclear_screen_start(FILE *); + +#define ESC_CLEAR_SCREEN_END CSI "0J" +void esc_clear_screen_end(void); +void esc_fclear_screen_end(FILE *); + +#define ESC_CLEAR_LINE CSI "2K" +void esc_clear_line(void); +void esc_fclear_line(FILE *); + +#define ESC_CLEAR_LINE_START CSI "1K" +void esc_clear_line_start(void); +void esc_fclear_line_start(FILE *); + +#define ESC_CLEAR_LINE_END CSI "0K" +void esc_clear_line_end(void); +void esc_fclear_line_end(FILE *); + +#define ESC_SCROLL_UP(n) CSI #n "S" +void esc_scroll_up(u32 n); +void esc_fscroll_up(FILE *, u32 n); + +#define ESC_SCROLL_DOWN(n) CSI #n "T" +void esc_scroll_down(u32 n); +void esc_fscroll_down(FILE *, u32 n); + +#define ESC_SGR "m" + +#define ESC_RESET CSI "0" ESC_SGR +#define ESC_BOLD CSI "1" ESC_SGR +#define ESC_DIM CSI "2" ESC_SGR +#define ESC_ITALIC CSI "3" ESC_SGR +#define ESC_UNDERLINE CSI "4" ESC_SGR +#define ESC_BLINK CSI "5" ESC_SGR +#define ESC_REVERSE CSI "7" ESC_SGR +#define ESC_CONCEAL CSI "8" ESC_SGR +#define ESC_REVEAL CSI "28" ESC_SGR +#define ESC_STRIKETHROUGH CSI "9" ESC_SGR +#define ESC_OVERLINE CSI "53" ESC_SGR + +#define ESC_COLOR_FG_RGB(r, g, b) CSI "38;2;" #r ";" #g ";" #b ESC_SGR +void esc_color_fg_rgb(u32 r, u32 g, u32 b); +void esc_fcolor_fg_rgb(FILE *, u32 r, u32 g, u32 b); + +#define ESC_COLOR_BG_RGB(r, g, b) CSI "48;2;" #r ";" #g ";" #b ESC_SGR +void esc_color_bg_rgb(u32 r, u32 g, u32 b); +void esc_fcolor_bg_rgb(FILE *, u32 r, u32 g, u32 b); + +#define ESC_COLOR_FG_256(n) CSI "38;5;" #n ESC_SGR +void esc_color_fg_256(u32 n); +void esc_fcolor_fg_256(FILE *, u32 n); + +#define ESC_COLOR_BG_256(n) CSI "48;5;" #n ESC_SGR +void esc_color_bg_256(u32 n); +void esc_fcolor_bg_256(FILE *, u32 n); + +#define ESC_COLOR_FG(n) CSI "3" #n ESC_SGR +#define ESC_COLOR_BG(n) CSI "4" #n ESC_SGR +#define ESC_COLOR_FG_BRIGHT(n) CSI "9" #n ESC_SGR +#define ESC_COLOR_BG_BRIGHT(n) CSI "10" #n ESC_SGR + +/* Named standard colors (ANSI 0-7). */ +#define ESC_COLOR_BLACK ESC_COLOR_FG(0) +#define ESC_COLOR_RED ESC_COLOR_FG(1) +#define ESC_COLOR_GREEN ESC_COLOR_FG(2) +#define ESC_COLOR_YELLOW ESC_COLOR_FG(3) +#define ESC_COLOR_BLUE ESC_COLOR_FG(4) +#define ESC_COLOR_MAGENTA ESC_COLOR_FG(5) +#define ESC_COLOR_CYAN ESC_COLOR_FG(6) +#define ESC_COLOR_WHITE ESC_COLOR_FG(7) + +#define ESC_CURSOR_HIDE CSI "?25l" +void esc_cursor_hide(void); +void esc_fcursor_hide(FILE *); + +#define ESC_CURSOR_SHOW CSI "?25h" +void esc_cursor_show(void); +void esc_fcursor_show(FILE *); + +#define ESC_CURSOR_SAVE CSI "s" +void esc_cursor_save(void); +void esc_fcursor_save(FILE *); + +#define ESC_CURSOR_RESTORE CSI "u" +void esc_cursor_restore(void); +void esc_fcursor_restore(FILE *); + +#define ESC_BELL "\x07" +void esc_bell(void); +void esc_fbell(FILE *); + +#define ESC_ALT_SCREEN_ON CSI "?1049h" +void esc_alt_screen_on(void); +void esc_falt_screen_on(FILE *); + +#define ESC_ALT_SCREEN_OFF CSI "?1049l" +void esc_alt_screen_off(void); +void esc_falt_screen_off(FILE *); + +#define ESC_BRACKETED_PASTE_ON CSI "?2004h" +void esc_bracketed_paste_on(void); +void esc_fbracketed_paste_on(FILE *); + +#define ESC_BRACKETED_PASTE_OFF CSI "?2004l" +void esc_bracketed_paste_off(void); +void esc_fbracketed_paste_off(FILE *); + +#define ESC_KITTY_ON CSI ">1u" +void esc_kitty_on(void); +void esc_fkitty_on(FILE *); + +#define ESC_KITTY_OFF CSI ">0u" +void esc_kitty_off(void); +void esc_fkitty_off(FILE *); + +#ifdef __cplusplus +} +#endif + +#endif // ESCAPE_H + +#ifdef ESCAPE_IMPLEMENTATION + +#include <stdio.h> + +void esc_fcursor_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uA", (unsigned)n); } +void esc_fcursor_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uB", (unsigned)n); } +void esc_fcursor_left(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uD", (unsigned)n); } +void esc_fcursor_right(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uC", (unsigned)n); } +void esc_fcursor_line_next(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uE", (unsigned)n); } +void esc_fcursor_previous(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uF", (unsigned)n); } +void esc_fcursor_position(FILE *f, u32 col, u32 row) { + if (!f) return; + fprintf(f, CSI "%u;%uH", (unsigned)row, (unsigned)col); +} +void esc_fclear_screen(FILE *f) { if (!f) return; fputs(CSI "2J", f); } +void esc_fclear_screen_start(FILE *f) { if (!f) return; fputs(CSI "1J", f); } +void esc_fclear_screen_end(FILE *f) { if (!f) return; fputs(CSI "0J", f); } +void esc_fclear_line(FILE *f) { if (!f) return; fputs(CSI "2K", f); } +void esc_fclear_line_start(FILE *f) { if (!f) return; fputs(CSI "1K", f); } +void esc_fclear_line_end(FILE *f) { if (!f) return; fputs(CSI "0K", f); } +void esc_fscroll_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uS", (unsigned)n); } +void esc_fscroll_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uT", (unsigned)n); } + +void esc_fcolor_fg_rgb(FILE *f, u32 r, u32 g, u32 b) { + if (!f) return; + fprintf(f, CSI "38;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b); +} +void esc_fcolor_bg_rgb(FILE *f, u32 r, u32 g, u32 b) { + if (!f) return; + fprintf(f, CSI "48;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b); +} +void esc_fcolor_fg_256(FILE *f, u32 n) { + if (!f) return; + fprintf(f, CSI "38;5;%u" ESC_SGR, (unsigned)n); +} +void esc_fcolor_bg_256(FILE *f, u32 n) { + if (!f) return; + fprintf(f, CSI "48;5;%u" ESC_SGR, (unsigned)n); +} + +void esc_fcursor_hide(FILE *f) { if (!f) return; fputs(CSI "?25l", f); } +void esc_fcursor_show(FILE *f) { if (!f) return; fputs(CSI "?25h", f); } +void esc_fcursor_save(FILE *f) { if (!f) return; fputs(CSI "s", f); } +void esc_fcursor_restore(FILE *f) { if (!f) return; fputs(CSI "u", f); } +void esc_fbell(FILE *f) { if (!f) return; fputc('\a', f); } +void esc_falt_screen_on(FILE *f) { if (!f) return; fputs(CSI "?1049h", f); } +void esc_falt_screen_off(FILE *f) { if (!f) return; fputs(CSI "?1049l", f); } +void esc_fbracketed_paste_on(FILE *f) { if (!f) return; fputs(CSI "?2004h", f); } +void esc_fbracketed_paste_off(FILE *f) { if (!f) return; fputs(CSI "?2004l", f); } +void esc_fkitty_on(FILE *f) { if (!f) return; fputs(CSI ">1u", f); } +void esc_fkitty_off(FILE *f) { if (!f) return; fputs(CSI ">0u", f); } + +void esc_cursor_up(u32 n) { esc_fcursor_up(stdout, n); } +void esc_cursor_down(u32 n) { esc_fcursor_down(stdout, n); } +void esc_cursor_left(u32 n) { esc_fcursor_left(stdout, n); } +void esc_cursor_right(u32 n) { esc_fcursor_right(stdout, n); } +void esc_cursor_line_next(u32 n) { esc_fcursor_line_next(stdout, n); } +void esc_cursor_previous(u32 n) { esc_fcursor_previous(stdout, n); } +void esc_cursor_position(u32 col, u32 row) { + esc_fcursor_position(stdout, col, row); +} +void esc_clear_screen(void) { esc_fclear_screen(stdout); } +void esc_clear_screen_start(void) { esc_fclear_screen_start(stdout); } +void esc_clear_screen_end(void) { esc_fclear_screen_end(stdout); } +void esc_clear_line(void) { esc_fclear_line(stdout); } +void esc_clear_line_start(void) { esc_fclear_line_start(stdout); } +void esc_clear_line_end(void) { esc_fclear_line_end(stdout); } +void esc_scroll_up(u32 n) { esc_fscroll_up(stdout, n); } +void esc_scroll_down(u32 n) { esc_fscroll_down(stdout, n); } + +void esc_color_fg_rgb(u32 r, u32 g, u32 b) { + esc_fcolor_fg_rgb(stdout, r, g, b); +} +void esc_color_bg_rgb(u32 r, u32 g, u32 b) { + esc_fcolor_bg_rgb(stdout, r, g, b); +} +void esc_color_fg_256(u32 n) { + esc_fcolor_fg_256(stdout, n); +} +void esc_color_bg_256(u32 n) { + esc_fcolor_bg_256(stdout, n); +} + +void esc_cursor_hide(void) { esc_fcursor_hide(stdout); } +void esc_cursor_show(void) { esc_fcursor_show(stdout); } +void esc_cursor_save(void) { esc_fcursor_save(stdout); } +void esc_cursor_restore(void) { esc_fcursor_restore(stdout); } +void esc_bell(void) { esc_fbell(stdout); } +void esc_alt_screen_on(void) { esc_falt_screen_on(stdout); } +void esc_alt_screen_off(void) { esc_falt_screen_off(stdout); } +void esc_bracketed_paste_on(void) { esc_fbracketed_paste_on(stdout); } +void esc_bracketed_paste_off(void) { esc_fbracketed_paste_off(stdout); } +void esc_kitty_on(void) { esc_fkitty_on(stdout); } +void esc_kitty_off(void) { esc_fkitty_off(stdout); } + +#endif // ESCAPE_IMPLEMENTATION
\ No newline at end of file diff --git a/include/frontmatter.h b/include/frontmatter.h new file mode 100644 index 0000000..56c6935 --- /dev/null +++ b/include/frontmatter.h @@ -0,0 +1,1058 @@ +/* frontmatter.h — portable, header-only markdown frontmatter library. + * + * Parses a markdown document with an optional `---` fenced frontmatter block + * into typed values. Zero dependencies: libc only. Not full YAML: simple + * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields + * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`). + * + * Format: + * - Frontmatter is delimited by a leading `---` line and a closing `---` + * line. An unterminated fence (no closing `---`) means the whole text is + * body — no metadata. + * - Lines are `key: value`. Blank lines, `#` comments, and lines without a + * colon are skipped. Keys and values are trimmed. + * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list + * (JSON array; non-string elements are serialized); anything else → + * string (quotes stripped if present). + * - The body is everything after the closing fence, trailing whitespace + * trimmed. Documents without frontmatter: whole text, trailing + * whitespace trimmed. + * + * Usage: #include "frontmatter.h" in your code. The header is complete on + * its own; all functions are static inline, safe to include in any number + * of translation units. + * + * API: + * parse_frontmatter(text, &doc) split frontmatter + body + * doc_get(&doc, "key") value or NULL + * doc_free(&doc) free the document + * fm_value_free(&val) free one value + * fm_list_items(&val, &items) list-valued field as string items + * fm_json_parse(text) embedded JSON value or NULL (invalid) + * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v) + * fm_json_merge_owned(base, over) shallow object merge; owns both inputs + * + * Concurrency: no shared state. Each call is self-contained. + */ +#ifndef FRONTMATTER_H +#define FRONTMATTER_H + +#include <ctype.h> +#include <errno.h> +#include <stdarg.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------ */ + +static inline void *fm_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "frontmatter: out of memory\n"); + abort(); + } + return p; +} + +static inline void *fm_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "frontmatter: out of memory\n"); + abort(); + } + return q; +} + +static inline char *fm_strdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *)fm_xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *fm_strndup(const char *s, size_t n) { + char *p = (char *)fm_xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *fm_vsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return fm_strdup(""); + char *p = (char *)fm_xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +static inline char *fm_strfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = fm_vsnprintf(fmt, ap); + va_end(ap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* Growable byte buffer (NUL-terminated on steal) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *data; + size_t len; + size_t cap; +} fm_buf; + +static inline void fm_buf_init(fm_buf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void fm_buf_free(fm_buf *b) { + free(b->data); + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void fm_buf_reset(fm_buf *b) { + b->len = 0; +} + +static inline void fm_buf_reserve(fm_buf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *)fm_xrealloc(b->data, cap); + b->cap = cap; + } +} + +static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) { + fm_buf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static inline void fm_buf_put(fm_buf *b, const char *s) { + if (s) + fm_buf_putn(b, s, strlen(s)); +} + +static inline void fm_buf_push(fm_buf *b, char c) { + fm_buf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +static inline int fm_buf_fmt(fm_buf *b, const char *fmt, ...) { + va_list ap, ap2; + if (!fmt) + return 0; + va_start(ap, fmt); + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) { + va_end(ap); + return -1; + } + fm_buf_reserve(b, (size_t)n); + vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap); + va_end(ap); + b->len += (size_t)n; + return n; +} + +static inline char *fm_buf_steal(fm_buf *b) { + if (!b->data) + b->data = (char *)fm_xmalloc(1); + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = 0; + b->cap = 0; + return s; +} + +/* ------------------------------------------------------------------ */ +/* String list */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char **items; + size_t len; + size_t cap; +} fm_strlist; + +static inline void fm_sl_init(fm_strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +static inline void fm_sl_free(fm_strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + fm_sl_init(l); +} + +static inline void fm_sl_push(fm_strlist *l, char *owned) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = owned; +} + +static inline void fm_sl_push_copy(fm_strlist *l, const char *s) { + fm_sl_push(l, fm_strdup(s)); +} + +static inline void fm_sl_pop(fm_strlist *l) { + if (!l->len) + return; + free(l->items[--l->len]); +} + +/* ------------------------------------------------------------------ */ +/* Embedded JSON values */ +/* Object, array, string, number, bool, null. Parse, serialize, */ +/* lookup, shallow-merge. */ +/* ------------------------------------------------------------------ */ + +enum { + FMJ_OBJECT, + FMJ_ARRAY, + FMJ_STRING, + FMJ_NUMBER, + FMJ_BOOL, + FMJ_NULL, +}; + +typedef struct fm_json { + int type; + char *key; /* OBJECT member key (owned), else NULL */ + char *str; /* FMJ_STRING value (owned), else NULL */ + double num; /* FMJ_NUMBER */ + int is_int; /* FMJ_NUMBER was an integer literal in range */ + long long i; /* FMJ_NUMBER integral value */ + int b; /* FMJ_BOOL */ + struct fm_json **items; /* OBJECT/ARRAY members (owned) */ + size_t count; +} fm_json; + +static inline fm_json *fm_json_new(int type) { + fm_json *v = (fm_json *)fm_xmalloc(sizeof *v); + memset(v, 0, sizeof *v); + v->type = type; + return v; +} + +static inline void fm_json_free(fm_json *v) { + if (!v) + return; + for (size_t i = 0; i < v->count; i++) + fm_json_free(v->items[i]); + free(v->items); + free(v->key); + free(v->str); + free(v); +} + +static inline const fm_json *fm_json_get(const fm_json *o, const char *key) { + if (!o || o->type != FMJ_OBJECT || !key) + return NULL; + for (size_t i = 0; i < o->count; i++) + if (o->items[i] && o->items[i]->key && + strcmp(o->items[i]->key, key) == 0) + return o->items[i]; + return NULL; +} + +/* Max nesting depth for the recursive-descent JSON parser; bounds stack + * growth so a pathologically deep input (e.g. 100k nested arrays) is + * rejected with an error instead of overflowing the C stack. */ +#define FM_JSON_MAX_DEPTH 256 + +typedef struct { + const char *p; + const char *end; + int err; + size_t depth; +} fm_jp; + +static inline void fm_jskip(fm_jp *s) { + while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' || + *s->p == '\r')) + s->p++; +} + +/* String at s->p (opening quote). Returns owned unescaped text. */ +static inline char *fm_jstring_raw(fm_jp *s) { + if (s->p >= s->end || *s->p != '"') { + s->err = 1; + return NULL; + } + s->p++; + fm_buf b; + fm_buf_init(&b); + while (s->p < s->end && *s->p != '"') { + char c = *s->p++; + if (c == '\\' && s->p < s->end) { + char e = *s->p++; + switch (e) { + case '"': + fm_buf_push(&b, '"'); + break; + case '\\': + fm_buf_push(&b, '\\'); + break; + case '/': + fm_buf_push(&b, '/'); + break; + case 'b': + fm_buf_push(&b, '\b'); + break; + case 'f': + fm_buf_push(&b, '\f'); + break; + case 'n': + fm_buf_push(&b, '\n'); + break; + case 'r': + fm_buf_push(&b, '\r'); + break; + case 't': + fm_buf_push(&b, '\t'); + break; + case 'u': { + if (s->end - s->p < 4) + goto fail; + unsigned h = 0; + bool ok = true; + for (int k = 0; k < 4; k++) { + char hc = s->p[k]; + int d; + if (hc >= '0' && hc <= '9') + d = hc - '0'; + else if (hc >= 'a' && hc <= 'f') + d = hc - 'a' + 10; + else if (hc >= 'A' && hc <= 'F') + d = hc - 'A' + 10; + else { + ok = false; + break; + } + h = h * 16 + (unsigned)d; + } + if (!ok) + goto fail; + s->p += 4; + /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by + * low \uDC00-\uDFFF) into a single codepoint so astral chars + * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than + * two CESU-8 3-byte sequences. Lone surrogates fall through to + * the 3-byte (lenient) encoding below. */ + unsigned cp = h; + if (h >= 0xD800u && h <= 0xDBFFu && + s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') { + unsigned lo = 0; + bool lok = true; + for (int k = 0; k < 4; k++) { + char hc = s->p[2 + k]; + int d; + if (hc >= '0' && hc <= '9') + d = hc - '0'; + else if (hc >= 'a' && hc <= 'f') + d = hc - 'a' + 10; + else if (hc >= 'A' && hc <= 'F') + d = hc - 'A' + 10; + else { + lok = false; + break; + } + lo = lo * 16 + (unsigned)d; + } + if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) { + cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u); + s->p += 6; + } + } + if (cp < 0x80) { + fm_buf_push(&b, (char)cp); + } else if (cp < 0x800) { + fm_buf_push(&b, (char)(0xC0 | (cp >> 6))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + fm_buf_push(&b, (char)(0xE0 | (cp >> 12))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } else { + fm_buf_push(&b, (char)(0xF0 | (cp >> 18))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F))); + fm_buf_push(&b, (char)(0x80 | (cp & 0x3F))); + } + break; + } + default: + goto fail; + } + } else if (c < 0x20) { + goto fail; /* raw control char: invalid JSON */ + } else { + fm_buf_push(&b, c); + } + } + if (s->p >= s->end || *s->p != '"') + goto fail; + s->p++; + return fm_buf_steal(&b); +fail: + s->err = 1; + fm_buf_free(&b); + return NULL; +} + +static inline fm_json *fm_jvalue(fm_jp *s); + +static inline fm_json *fm_jobject(fm_jp *s) { + if (s->depth >= FM_JSON_MAX_DEPTH) { + s->err = 1; + return NULL; + } + s->depth++; + s->p++; /* { */ + fm_json *o = fm_json_new(FMJ_OBJECT); + for (;;) { + fm_jskip(s); + if (s->p < s->end && *s->p == '}') { + s->p++; + s->depth--; + return o; + } + char *key = fm_jstring_raw(s); + if (!key) { + fm_json_free(o); + return NULL; + } + fm_jskip(s); + if (s->p >= s->end || *s->p != ':') { + free(key); + fm_json_free(o); + s->err = 1; + return NULL; + } + s->p++; + fm_json *v = fm_jvalue(s); + if (!v) { + free(key); + fm_json_free(o); + return NULL; + } + v->key = key; + o->items = + (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *)); + o->items[o->count++] = v; + fm_jskip(s); + if (s->p < s->end && *s->p == ',') { + s->p++; + continue; + } + if (s->p < s->end && *s->p == '}') { + s->p++; + s->depth--; + return o; + } + fm_json_free(o); + s->err = 1; + return NULL; + } +} + +static inline fm_json *fm_jarray(fm_jp *s) { + if (s->depth >= FM_JSON_MAX_DEPTH) { + s->err = 1; + return NULL; + } + s->depth++; + s->p++; /* [ */ + fm_json *a = fm_json_new(FMJ_ARRAY); + for (;;) { + fm_jskip(s); + if (s->p < s->end && *s->p == ']') { + s->p++; + s->depth--; + return a; + } + fm_json *v = fm_jvalue(s); + if (!v) { + fm_json_free(a); + return NULL; + } + a->items = + (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *)); + a->items[a->count++] = v; + fm_jskip(s); + if (s->p < s->end && *s->p == ',') { + s->p++; + continue; + } + if (s->p < s->end && *s->p == ']') { + s->p++; + s->depth--; + return a; + } + fm_json_free(a); + s->err = 1; + return NULL; + } +} + +static inline fm_json *fm_jvalue(fm_jp *s) { + fm_jskip(s); + if (s->p >= s->end) { + s->err = 1; + return NULL; + } + char c = *s->p; + if (c == '{') + return fm_jobject(s); + if (c == '[') + return fm_jarray(s); + if (c == '"') { + char *t = fm_jstring_raw(s); + if (!t) + return NULL; + fm_json *v = fm_json_new(FMJ_STRING); + v->str = t; + return v; + } + if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) { + s->p += 4; + fm_json *v = fm_json_new(FMJ_BOOL); + v->b = 1; + return v; + } + if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) { + s->p += 5; + fm_json *v = fm_json_new(FMJ_BOOL); + v->b = 0; + return v; + } + if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) { + s->p += 4; + return fm_json_new(FMJ_NULL); + } + if (c == '-' || (c >= '0' && c <= '9')) { + const char *start = s->p; + if (c == '-') + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' && + s->p[1] <= '9') { + s->err = 1; /* leading zero */ + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + int is_int = 1; + if (s->p < s->end && *s->p == '.') { + is_int = 0; + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + } + if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) { + is_int = 0; + s->p++; + if (s->p < s->end && (*s->p == '+' || *s->p == '-')) + s->p++; + if (s->p >= s->end || *s->p < '0' || *s->p > '9') { + s->err = 1; + return NULL; + } + while (s->p < s->end && *s->p >= '0' && *s->p <= '9') + s->p++; + } + char *endp = NULL; + errno = 0; + double d = strtod(start, &endp); + if (errno != 0 || endp == start) { + s->err = 1; + return NULL; + } + fm_json *v = fm_json_new(FMJ_NUMBER); + v->num = d; + if (is_int && d >= -9.0e18 && d <= 9.0e18) { + v->is_int = 1; + v->i = (long long)d; + } + return v; + } + s->err = 1; + return NULL; +} + +static inline fm_json *fm_json_parse(const char *text) { + if (!text) + return NULL; + fm_jp s = {text, text + strlen(text), 0, 0}; + fm_json *v = fm_jvalue(&s); + if (!v) + return NULL; + fm_jskip(&s); + if (s.p != s.end) { /* trailing garbage */ + fm_json_free(v); + return NULL; + } + return v; +} + +static inline void fm_jstring_out(fm_buf *b, const char *s) { + fm_buf_push(b, '"'); + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + unsigned char c = *p; + switch (c) { + case '"': + fm_buf_put(b, "\\\""); + break; + case '\\': + fm_buf_put(b, "\\\\"); + break; + case '\b': + fm_buf_put(b, "\\b"); + break; + case '\f': + fm_buf_put(b, "\\f"); + break; + case '\n': + fm_buf_put(b, "\\n"); + break; + case '\r': + fm_buf_put(b, "\\r"); + break; + case '\t': + fm_buf_put(b, "\\t"); + break; + default: + if (c < 0x20) + fm_buf_fmt(b, "\\u%04x", c); + else + fm_buf_push(b, (char)c); + } + } + fm_buf_push(b, '"'); +} + +static inline void fm_json_out(const fm_json *v, fm_buf *b) { + switch (v->type) { + case FMJ_NULL: + fm_buf_put(b, "null"); + break; + case FMJ_BOOL: + fm_buf_put(b, v->b ? "true" : "false"); + break; + case FMJ_STRING: + fm_jstring_out(b, v->str ? v->str : ""); + break; + case FMJ_NUMBER: + if (v->is_int) + fm_buf_fmt(b, "%lld", v->i); + else + fm_buf_fmt(b, "%.15g", v->num); + break; + case FMJ_ARRAY: + fm_buf_push(b, '['); + for (size_t i = 0; i < v->count; i++) { + if (i) + fm_buf_put(b, ","); + fm_json_out(v->items[i], b); + } + fm_buf_push(b, ']'); + break; + case FMJ_OBJECT: + fm_buf_push(b, '{'); + for (size_t i = 0; i < v->count; i++) { + if (i) + fm_buf_put(b, ","); + fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : ""); + fm_buf_put(b, ":"); + fm_json_out(v->items[i], b); + } + fm_buf_push(b, '}'); + break; + } +} + +static inline char *fm_json_serialize(const fm_json *v) { + fm_buf b; + fm_buf_init(&b); + if (v) + fm_json_out(v, &b); + return fm_buf_steal(&b); +} + +/* Shallow merge of two objects: union of keys, `over` wins per key. + * Takes ownership of both inputs (either may be NULL or non-object, in + * which case it is dropped). Returns a new object; free with fm_json_free. + * Member order: over's keys first, then base-only keys. */ +static inline fm_json *fm_json_merge_owned(const fm_json *base, + const fm_json *over) { + fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL; + fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL; + if (base && base->type != FMJ_OBJECT) + fm_json_free((fm_json *)base); + if (over && over->type != FMJ_OBJECT) + fm_json_free((fm_json *)over); + fm_json *r = fm_json_new(FMJ_OBJECT); + if (O) { + for (size_t i = 0; i < O->count; i++) + if (O->items[i]) { + r->items = + (fm_json **)fm_xrealloc(r->items, + (r->count + 1) * sizeof(fm_json *)); + r->items[r->count++] = O->items[i]; + } + } + if (B) { + for (size_t i = 0; i < B->count; i++) { + fm_json *m = B->items[i]; + if (!m) + continue; + if (O && fm_json_get(O, m->key)) + fm_json_free(m); /* superseded */ + else { + r->items = + (fm_json **)fm_xrealloc(r->items, + (r->count + 1) * sizeof(fm_json *)); + r->items[r->count++] = m; + } + } + } + if (B) { + free(B->items); + free(B->key); + free(B->str); + free(B); + } + if (O) { + free(O->items); + free(O->key); + free(O->str); + free(O); + } + return r; +} + +/* ------------------------------------------------------------------ */ +/* Frontmatter */ +/* ------------------------------------------------------------------ */ + +/* Frontmatter value */ +typedef enum { + FM_STR, + FM_LIST, + FM_BOOL, + FM_INT, +} fm_kind; + +typedef struct { + fm_kind kind; + char *s; + char **items; + size_t item_count; + bool b; + long long i; +} fm_value; + +static inline void fm_value_free(fm_value *v) { + free(v->s); + for (size_t i = 0; i < v->item_count; i++) + free(v->items[i]); + free(v->items); + memset(v, 0, sizeof *v); +} + +/* List-valued field as items: JSON array as-is, CSV split on commas, + * single string as one item. */ +static inline void fm_list_items(const fm_value *v, fm_strlist *out) { + fm_sl_init(out); + if (!v) + return; + if (v->kind == FM_LIST) { + for (size_t i = 0; i < v->item_count; i++) + fm_sl_push_copy(out, v->items[i]); + return; + } + if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) { + if (v->s) { + size_t a = 0, n = strlen(v->s); + while (a < n && isspace((unsigned char)v->s[a])) + a++; + while (n > a && isspace((unsigned char)v->s[n - 1])) + n--; + if (n > a) + fm_sl_push(out, fm_strndup(v->s + a, n - a)); + } + return; + } + const char *s = v->s; + size_t n = strlen(s); + size_t start = 0; + for (;;) { + while (start < n && isspace((unsigned char)s[start])) + start++; + if (start >= n) + break; + const char *comma = (const char *)memchr(s + start, ',', n - start); + size_t seg_end = comma ? (size_t)(comma - s) : n; + size_t a = start, b = seg_end; + while (b > a && isspace((unsigned char)s[b - 1])) + b--; + if (b > a) + fm_sl_push(out, fm_strndup(s + a, b - a)); + if (!comma) + break; + start = seg_end + 1; + } +} + +/* A frontmatter entry */ +typedef struct { + char *key; + fm_value val; +} fm_entry; + +/* A markdown document with optional `---` frontmatter */ +typedef struct { + fm_entry *entries; + size_t count; + size_t cap; + char *body; +} doc; + +static inline const fm_value *doc_get(const doc *d, const char *key) { + for (size_t i = 0; i < d->count; i++) + if (strcmp(d->entries[i].key, key) == 0) + return &d->entries[i].val; + return NULL; +} + +static inline void doc_free(doc *d) { + for (size_t i = 0; i < d->count; i++) { + free(d->entries[i].key); + fm_value_free(&d->entries[i].val); + } + free(d->entries); + free(d->body); + memset(d, 0, sizeof *d); +} + +static inline void fm_parse_value(const char *raw, fm_value *v) { + memset(v, 0, sizeof *v); + if (raw[0] == '[') { + fm_json *arr = fm_json_parse(raw); + if (arr && arr->type == FMJ_ARRAY) { + v->kind = FM_LIST; + v->items = (char **)fm_xmalloc(arr->count * sizeof(char *)); + size_t i = 0; + for (size_t k = 0; k < arr->count; k++) { + const fm_json *it = arr->items[k]; + if (it && it->type == FMJ_STRING) + v->items[i++] = fm_strdup(it->str); + else + v->items[i++] = it ? fm_json_serialize(it) + : fm_strdup(""); + } + v->item_count = arr->count; + fm_json_free(arr); + return; + } + fm_json_free(arr); + v->kind = FM_STR; + v->s = fm_strdup(raw); + return; + } + if (strcmp(raw, "true") == 0) { + v->kind = FM_BOOL; + v->b = true; + return; + } + if (strcmp(raw, "false") == 0) { + v->kind = FM_BOOL; + v->b = false; + return; + } + size_t len = strlen(raw); + if (len >= 2 && + ((raw[0] == '"' && raw[len - 1] == '"') || + (raw[0] == '\'' && raw[len - 1] == '\''))) { + v->kind = FM_STR; + v->s = fm_strndup(raw + 1, len - 2); + return; + } + /* integer? */ + char *end = NULL; + errno = 0; + long long n = strtoll(raw, &end, 10); + if (errno == 0 && end && *end == '\0' && raw[0] != '\0') { + v->kind = FM_INT; + v->i = n; + return; + } + v->kind = FM_STR; + v->s = fm_strdup(raw); +} + +/* Split text into lines (no trailing empty). Returns line count. */ +static inline char **fm_split_lines(const char *text, size_t *out_count) { + size_t cap = 16, n = 0; + char **lines = (char **)fm_xmalloc(cap * sizeof(char *)); + const char *p = text; + while (*p) { + const char *nl = (const char *)strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (n + 1 > cap) { + cap *= 2; + lines = (char **)fm_xrealloc(lines, cap * sizeof(char *)); + } + lines[n++] = fm_strndup(p, len); + if (!nl) + break; + p = nl + 1; + } + *out_count = n; + return lines; +} + +static inline void parse_frontmatter(const char *text, doc *out) { + memset(out, 0, sizeof *out); + size_t nlines = 0; + char **lines = fm_split_lines(text, &nlines); + + char *body = fm_strdup(text); /* default: whole text */ + if (nlines == 0) { + /* keep as-is */ + } else if (strcmp(lines[0], "---") != 0) { + /* trim end */ + size_t blen = strlen(body); + while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' || + body[blen - 1] == ' ')) + body[--blen] = '\0'; + } else { + size_t close = 0; + bool found = false; + for (size_t i = 1; i < nlines; i++) { + if (strcmp(lines[i], "---") == 0) { + close = i - 1; /* index within lines[1..] */ + found = true; + break; + } + } + if (!found) { + /* whole text is body, trimmed */ + size_t blen = strlen(body); + while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' || + body[blen - 1] == ' ')) + body[--blen] = '\0'; + } else { + for (size_t i = 1; i <= close; i++) { + const char *line = lines[i]; + /* trim */ + size_t start = 0, len = strlen(line); + while (start < len && isspace((unsigned char)line[start])) + start++; + while (len > start && + isspace((unsigned char)line[len - 1])) + len--; + if (len == 0 || line[start] == '#') + continue; + const char *colon = + (const char *)memchr(line + start, ':', len - start); + if (!colon) + continue; + size_t klen = (size_t)(colon - (line + start)); + if (klen == 0) + continue; + char *key = fm_strndup(line + start, klen); + const char *vraw = colon + 1; + /* trim value */ + size_t vlen = strlen(vraw); + size_t vstart = 0; + while (vstart < vlen && + isspace((unsigned char)vraw[vstart])) + vstart++; + while (vlen > vstart && + isspace((unsigned char)vraw[vlen - 1])) + vlen--; + fm_value val; + char *vtmp = fm_strndup(vraw + vstart, vlen - vstart); + fm_parse_value(vtmp, &val); + free(vtmp); + if (out->count + 1 > out->cap) { + out->cap = out->cap ? out->cap * 2 : 8; + out->entries = + (fm_entry *)fm_xrealloc(out->entries, + out->cap * + sizeof(out->entries[0])); + } + out->entries[out->count].key = key; + out->entries[out->count].val = val; + out->count++; + } + /* body = lines[2+close..] joined by \n, trimmed end */ + fm_buf b; + fm_buf_init(&b); + for (size_t i = 2 + close; i < nlines; i++) { + if (i > 2 + close) + fm_buf_push(&b, '\n'); + fm_buf_put(&b, lines[i]); + } + char *nb = fm_buf_steal(&b); + size_t blen = strlen(nb); + while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' || + nb[blen - 1] == ' ')) + nb[--blen] = '\0'; + free(body); + body = nb; + } + } + for (size_t i = 0; i < nlines; i++) + free(lines[i]); + free(lines); + out->body = body; +} + +#ifdef __cplusplus +} +#endif + +#endif /* FRONTMATTER_H */ diff --git a/include/type.h b/include/type.h new file mode 100644 index 0000000..1106832 --- /dev/null +++ b/include/type.h @@ -0,0 +1,24 @@ +// type.h - Common type aliases +// +// Used across header libraries to provide consistent, concise type names +// for fixed-width integers and floating point types. + +#ifndef TYPE_H +#define TYPE_H + +#include <stdint.h> + +typedef double f64; +typedef float f32; + +typedef int8_t i8; +typedef int16_t i16; +typedef int32_t i32; +typedef int64_t i64; + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; + +#endif |