Images - vision gate: text-only models get a note instead of image parts and no view_image tool; unknown models retry once without images on HTTP 400 - paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip, pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image file paths attaches them - MCP image content is stored in the session and sent with the tool result - attach: rel path validation, base64 decode, /retry keeps images Model registry - built-in src/models_builtin.json embedded at build time, merged with <config>/models.json and cached GET /models metadata (context, vision, pricing); most specific rule wins per field - /model labels, /status source, --list-models [provider] Cache and cost - stable request prefix (frozen tools, notes as messages), DeepSeek prefix completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
1436 lines
41 KiB
C
1436 lines
41 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
#ifndef _XOPEN_SOURCE
|
|
#define _XOPEN_SOURCE 700
|
|
#endif
|
|
|
|
#include <ctype.h>
|
|
#include <errno.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
|
|
#include <regex.h>
|
|
|
|
#include "cst_attach.h"
|
|
#include "cst_tools.h"
|
|
#include "cst_util.h"
|
|
#include "os_compat.h"
|
|
#include "tools_internal.h"
|
|
|
|
#define EDIT_FILE_MAX (8 * 1024 * 1024)
|
|
#define RANGE_OUT_MAX (256 * 1024)
|
|
#define RANGE_DEFAULT_LINES 2000
|
|
#define LIST_MAX 500
|
|
#define GLOB_MAX 200
|
|
#define GREP_MAX_MATCHES 200
|
|
#define GREP_MAX_FILES 20000
|
|
#define GREP_LINE_MAX 300
|
|
|
|
static int resolve(const cst_tool_ctx_t *ctx, const char *path, char *full,
|
|
size_t n) {
|
|
return cst_sandbox_resolve(ctx->workspace_root, path, full, n);
|
|
}
|
|
|
|
static int is_dir(const char *full) {
|
|
struct stat st;
|
|
return stat(full, &st) == 0 && S_ISDIR(st.st_mode);
|
|
}
|
|
|
|
static int is_regular(const char *full) {
|
|
struct stat st;
|
|
return stat(full, &st) == 0 && S_ISREG(st.st_mode);
|
|
}
|
|
|
|
/* --- read_file ----------------------------------------------------------- */
|
|
|
|
#define READ_LINE_MAX 2000
|
|
|
|
/* Default-profile read: "path · lines a-b of N" header, `N|` numbers,
|
|
* long lines cut, and the next offset when more remains. */
|
|
static int read_compact(FILE *fp, const char *path, long offset, long limit,
|
|
int numbers, cst_tool_result_t *r) {
|
|
if (offset <= 0)
|
|
offset = 1;
|
|
if (limit <= 0)
|
|
limit = RANGE_DEFAULT_LINES;
|
|
cst_buf_t b = {0}, line = {0};
|
|
long lineno = 0, shown = 0, cut_lines = 0;
|
|
int full = 0, binary = 0, ch;
|
|
for (;;) {
|
|
line.len = 0;
|
|
int got = 0, cr = 0;
|
|
size_t len = 0;
|
|
while ((ch = fgetc(fp)) != EOF) {
|
|
got = 1;
|
|
if (ch == 0)
|
|
binary = 1;
|
|
if (ch == '\n')
|
|
break;
|
|
len++;
|
|
cr = ch == '\r';
|
|
if (line.len < READ_LINE_MAX + 8) {
|
|
char cc = (char)ch;
|
|
cst_buf_append(&line, &cc, 1);
|
|
}
|
|
}
|
|
if (!got || binary)
|
|
break;
|
|
lineno++;
|
|
if (lineno < offset || shown >= limit || full)
|
|
continue;
|
|
len -= (size_t)cr;
|
|
if (b.len + (len < READ_LINE_MAX ? len : READ_LINE_MAX) > RANGE_OUT_MAX) {
|
|
full = 1;
|
|
continue;
|
|
}
|
|
if (numbers)
|
|
cst_buf_printf(&b, "%ld|", lineno);
|
|
if (len > READ_LINE_MAX) {
|
|
size_t keep = cst_utf8_floor(line.buf, READ_LINE_MAX);
|
|
cst_buf_append(&b, line.buf, keep);
|
|
cst_buf_printf(&b, " … [line cut, +%zu bytes]\n", len - keep);
|
|
cut_lines++;
|
|
} else {
|
|
if (len)
|
|
cst_buf_append(&b, line.buf, len);
|
|
cst_buf_puts(&b, "\n");
|
|
}
|
|
shown++;
|
|
}
|
|
cst_buf_free(&line);
|
|
if (binary) {
|
|
cst_buf_free(&b);
|
|
return tool_set_resultf(r, 1,
|
|
"error: %.200s is a binary file; inspect it with "
|
|
"run_shell (file, xxd | head)",
|
|
path);
|
|
}
|
|
long last = offset + shown - 1;
|
|
cst_buf_t out = {0};
|
|
if (lineno == 0)
|
|
cst_buf_printf(&out, "%s · empty file\n", path);
|
|
else if (shown == 0)
|
|
cst_buf_printf(&out, "%s · %ld line%s; offset %ld is past the end\n", path,
|
|
lineno, lineno == 1 ? "" : "s", offset);
|
|
else if (offset == 1 && last == lineno)
|
|
cst_buf_printf(&out, "%s · %ld line%s\n", path, lineno,
|
|
lineno == 1 ? "" : "s");
|
|
else
|
|
cst_buf_printf(&out, "%s · lines %ld-%ld of %ld\n", path, offset, last,
|
|
lineno);
|
|
if (b.buf)
|
|
cst_buf_append(&out, b.buf, b.len);
|
|
cst_buf_free(&b);
|
|
if (shown && last < lineno)
|
|
cst_buf_printf(&out, "… %ld more line%s; continue with offset=%ld\n",
|
|
lineno - last, lineno - last == 1 ? "" : "s", last + 1);
|
|
if (cut_lines)
|
|
cst_buf_printf(&out, "(%ld long line%s cut at %d bytes)\n", cut_lines,
|
|
cut_lines == 1 ? "" : "s", READ_LINE_MAX);
|
|
return tool_take_buf(r, &out, 0);
|
|
}
|
|
|
|
int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
const char *path = tool_arg(c, "path");
|
|
if (!path || !path[0])
|
|
return tool_set_result(r, "error: missing path", 1);
|
|
char full[OS_PATH_MAX];
|
|
if (resolve(ctx, path, full, sizeof(full)) != 0)
|
|
return tool_set_resultf(r, 1,
|
|
"error: path outside workspace or missing parent: "
|
|
"%.200s",
|
|
path);
|
|
if (is_dir(full))
|
|
return tool_set_resultf(r, 1, "error: %.200s is a directory (use list_dir)",
|
|
path);
|
|
FILE *fp = fopen(full, "rb");
|
|
if (!fp)
|
|
return tool_set_resultf(r, 1, "error: cannot open file: %.200s", path);
|
|
char bom[3];
|
|
if (fread(bom, 1, 3, fp) != 3 || !cst_bom_len(bom, 3))
|
|
rewind(fp);
|
|
|
|
long offset = tool_arg_long(c, "offset", 0);
|
|
long limit = tool_arg_long(c, "limit", 0);
|
|
int numbers = tool_arg_bool(c, "line_numbers", ctx->compact);
|
|
|
|
if (ctx->compact) {
|
|
int rc = read_compact(fp, path, offset, limit, numbers, r);
|
|
fclose(fp);
|
|
return rc;
|
|
}
|
|
if (offset <= 0 && limit <= 0 && !numbers) {
|
|
fclose(fp);
|
|
size_t n = 0;
|
|
char *buf = tool_slurp(full, &n, READ_FILE_MAX);
|
|
if (!buf)
|
|
return tool_set_resultf(r, 1,
|
|
"error: file too large (>2MiB) or unreadable: "
|
|
"%.200s (read a slice with offset/limit)",
|
|
path);
|
|
if (tool_is_binary(buf, n)) {
|
|
free(buf);
|
|
return tool_set_resultf(r, 1, "error: %.200s is a binary file (%zu bytes)",
|
|
path, n);
|
|
}
|
|
cst_strip_bom(buf, &n);
|
|
n = cst_crlf_to_lf(buf, n); /* edits match LF text and keep CRLF */
|
|
cst_tool_result_free(r);
|
|
r->out = buf;
|
|
r->out_len = n;
|
|
r->exit_code = 0;
|
|
return 0;
|
|
}
|
|
|
|
if (offset <= 0)
|
|
offset = 1;
|
|
if (limit <= 0)
|
|
limit = RANGE_DEFAULT_LINES;
|
|
cst_buf_t b = {0};
|
|
long lineno = 0, shown = 0;
|
|
int truncated = 0, binary = 0;
|
|
cst_buf_t line = {0};
|
|
int ch;
|
|
for (;;) {
|
|
line.len = 0;
|
|
int got = 0;
|
|
while ((ch = fgetc(fp)) != EOF) {
|
|
got = 1;
|
|
if (ch == 0)
|
|
binary = 1;
|
|
char cc = (char)ch;
|
|
cst_buf_append(&line, &cc, 1);
|
|
if (ch == '\n')
|
|
break;
|
|
}
|
|
if (!got || binary)
|
|
break;
|
|
lineno++;
|
|
if (lineno < offset || shown >= limit || truncated)
|
|
continue;
|
|
if (b.len + line.len > RANGE_OUT_MAX) {
|
|
truncated = 1;
|
|
continue;
|
|
}
|
|
if (numbers)
|
|
cst_buf_printf(&b, "%6ld|", lineno);
|
|
if (line.len >= 2 && line.buf[line.len - 2] == '\r' &&
|
|
line.buf[line.len - 1] == '\n')
|
|
line.buf[--line.len - 1] = '\n';
|
|
cst_buf_append(&b, line.buf, line.len);
|
|
shown++;
|
|
}
|
|
fclose(fp);
|
|
cst_buf_free(&line);
|
|
if (binary) {
|
|
cst_buf_free(&b);
|
|
return tool_set_resultf(r, 1, "error: %.200s is a binary file", path);
|
|
}
|
|
if (b.len && b.buf[b.len - 1] != '\n')
|
|
cst_buf_puts(&b, "\n");
|
|
long last = offset + shown - 1;
|
|
if (shown == 0)
|
|
cst_buf_printf(&b, "(no lines: file has %ld line%s)\n", lineno,
|
|
lineno == 1 ? "" : "s");
|
|
else if (last < lineno)
|
|
cst_buf_printf(&b, "... (lines %ld-%ld of %ld; next offset=%ld)\n",
|
|
offset, last, lineno, last + 1);
|
|
return tool_take_buf(r, &b, 0);
|
|
}
|
|
|
|
/* --- view_image ---------------------------------------------------------- */
|
|
|
|
/* Show an image to the model: the bytes are copied into the session's
|
|
* attachment store and travel as a multimodal content part of the result. */
|
|
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
const char *path = tool_arg(c, "path");
|
|
if (!path || !path[0])
|
|
return tool_set_result(r, "error: missing path", 1);
|
|
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
|
|
if (!session || !session[0])
|
|
return tool_set_result(r, "error: no session to store the image in", 1);
|
|
char full[OS_PATH_MAX];
|
|
if (resolve(ctx, path, full, sizeof(full)) != 0)
|
|
return tool_set_resultf(r, 1,
|
|
"error: path outside workspace or missing parent: "
|
|
"%.200s",
|
|
path);
|
|
if (is_dir(full))
|
|
return tool_set_resultf(r, 1, "error: %.200s is a directory", path);
|
|
|
|
char err[256];
|
|
cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err));
|
|
if (!a)
|
|
return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err);
|
|
snprintf(a->origin, sizeof(a->origin), "view_image");
|
|
const char *d = tool_arg(c, "detail");
|
|
if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0))
|
|
snprintf(a->detail, sizeof(a->detail), "%s", d);
|
|
|
|
char desc[128];
|
|
cst_attach_describe(a, desc, sizeof(desc));
|
|
cst_buf_t out = {0};
|
|
cst_buf_printf(&out, "view_image %.200s: %s\n(shown to you as an image)\n",
|
|
path, desc);
|
|
int rc = tool_take_buf(r, &out, 0);
|
|
r->attach = a; /* one entry; freed as a block by cst_tool_result_free */
|
|
r->nattach = 1;
|
|
return rc;
|
|
}
|
|
|
|
/* --- edit_file ----------------------------------------------------------- */
|
|
|
|
static size_t count_occ(const char *hay, size_t hn, const char *needle,
|
|
size_t nn) {
|
|
size_t cnt = 0;
|
|
if (!nn)
|
|
return 0;
|
|
for (size_t i = 0; i + nn <= hn;) {
|
|
const char *p = memchr(hay + i, needle[0], hn - i - nn + 1);
|
|
if (!p)
|
|
break;
|
|
i = (size_t)(p - hay);
|
|
if (memcmp(p, needle, nn) == 0) {
|
|
cnt++;
|
|
i += nn;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
return cnt;
|
|
}
|
|
|
|
/* "12, 40, 77" — line numbers of the first few occurrences. */
|
|
static void occ_lines(const char *hay, size_t hn, const char *nd, size_t nn,
|
|
char *out, size_t out_sz) {
|
|
out[0] = 0;
|
|
size_t used = 0;
|
|
long line = 1;
|
|
size_t last = 0;
|
|
int k = 0;
|
|
for (size_t i = 0; nn && i + nn <= hn && k < 6;) {
|
|
const char *p = memchr(hay + i, nd[0], hn - i - nn + 1);
|
|
if (!p)
|
|
break;
|
|
i = (size_t)(p - hay);
|
|
if (memcmp(p, nd, nn) != 0) {
|
|
i++;
|
|
continue;
|
|
}
|
|
for (size_t j = last; j < i; j++)
|
|
line += hay[j] == '\n';
|
|
last = i;
|
|
int w = snprintf(out + used, out_sz - used, "%s%ld", k ? ", " : "", line);
|
|
if (w < 0 || (size_t)w >= out_sz - used)
|
|
break;
|
|
used += (size_t)w;
|
|
k++;
|
|
i += nn;
|
|
}
|
|
}
|
|
|
|
static int trimmed_eq(const char *a, size_t an, const char *b, size_t bn) {
|
|
while (an && isspace((unsigned char)*a))
|
|
a++, an--;
|
|
while (an && isspace((unsigned char)a[an - 1]))
|
|
an--;
|
|
while (bn && isspace((unsigned char)*b))
|
|
b++, bn--;
|
|
while (bn && isspace((unsigned char)b[bn - 1]))
|
|
bn--;
|
|
return an && an == bn && memcmp(a, b, an) == 0;
|
|
}
|
|
|
|
/* old_string was not found: lines whose trimmed text equals old_string's
|
|
* first non-blank line, as "42, 97" (empty when none). */
|
|
static void near_lines(const char *hay, size_t hn, const char *old, size_t on,
|
|
char *out, size_t out_sz) {
|
|
out[0] = 0;
|
|
const char *f = old;
|
|
size_t fl = 0;
|
|
for (size_t i = 0; i < on;) {
|
|
const char *e = memchr(old + i, '\n', on - i);
|
|
size_t end = e ? (size_t)(e - old) : on;
|
|
int blank = 1;
|
|
for (size_t j = i; j < end; j++)
|
|
if (!isspace((unsigned char)old[j]))
|
|
blank = 0;
|
|
if (!blank) {
|
|
f = old + i;
|
|
fl = end - i;
|
|
break;
|
|
}
|
|
i = end + 1;
|
|
}
|
|
if (!fl)
|
|
return;
|
|
size_t used = 0;
|
|
long line = 1;
|
|
int k = 0;
|
|
for (size_t i = 0; i < hn && k < 5; line++) {
|
|
const char *e = memchr(hay + i, '\n', hn - i);
|
|
size_t end = e ? (size_t)(e - hay) : hn;
|
|
if (trimmed_eq(hay + i, end - i, f, fl)) {
|
|
int w = snprintf(out + used, out_sz - used, "%s%ld", k ? ", " : "", line);
|
|
if (w < 0 || (size_t)w >= out_sz - used)
|
|
break;
|
|
used += (size_t)w;
|
|
k++;
|
|
}
|
|
i = end + 1;
|
|
}
|
|
}
|
|
|
|
static char *replace_all(const char *hay, size_t hn, const char *old,
|
|
size_t on, const char *nw, size_t nwn, int all,
|
|
size_t *out_len) {
|
|
cst_buf_t b = {0};
|
|
size_t i = 0;
|
|
int done = 0;
|
|
while (i < hn) {
|
|
if (!(all || !done) || i + on > hn) {
|
|
cst_buf_append(&b, hay + i, hn - i);
|
|
break;
|
|
}
|
|
const char *p = NULL;
|
|
for (size_t j = i; j + on <= hn; j++) {
|
|
if (hay[j] == old[0] && memcmp(hay + j, old, on) == 0) {
|
|
p = hay + j;
|
|
break;
|
|
}
|
|
}
|
|
if (!p) {
|
|
cst_buf_append(&b, hay + i, hn - i);
|
|
break;
|
|
}
|
|
cst_buf_append(&b, hay + i, (size_t)(p - (hay + i)));
|
|
cst_buf_append(&b, nw, nwn);
|
|
i = (size_t)(p - hay) + on;
|
|
done = 1;
|
|
}
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
*out_len = b.len;
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* LF → CRLF copy (for files with Windows line endings). */
|
|
static char *to_crlf(const char *s, size_t n, size_t *out_len) {
|
|
cst_buf_t b = {0};
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (s[i] == '\n' && (i == 0 || s[i - 1] != '\r'))
|
|
cst_buf_append(&b, "\r", 1);
|
|
cst_buf_append(&b, s + i, 1);
|
|
}
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
*out_len = b.len;
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
typedef struct {
|
|
char full[OS_PATH_MAX];
|
|
char *oldc, *newc;
|
|
size_t olen, nlen;
|
|
size_t count;
|
|
} edit_plan_t;
|
|
|
|
static int plan_edit(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
edit_plan_t *pl, char *err, size_t err_sz) {
|
|
memset(pl, 0, sizeof(*pl));
|
|
const char *path = tool_arg(c, "path");
|
|
size_t on = 0, nwn = 0;
|
|
const char *old = tool_arg_n(c, "old_string", &on);
|
|
const char *nw = tool_arg_n(c, "new_string", &nwn);
|
|
int all = tool_arg_bool(c, "replace_all", 0);
|
|
if (!path || !path[0] || !old || !nw) {
|
|
snprintf(err, err_sz, "error: need path, old_string and new_string");
|
|
return -1;
|
|
}
|
|
if (on == 0) {
|
|
snprintf(err, err_sz,
|
|
"error: old_string is empty (use write_file to create a file)");
|
|
return -1;
|
|
}
|
|
if (on == nwn && memcmp(old, nw, on) == 0) {
|
|
snprintf(err, err_sz, "error: old_string and new_string are identical");
|
|
return -1;
|
|
}
|
|
if (resolve(ctx, path, pl->full, sizeof(pl->full)) != 0 ||
|
|
!is_regular(pl->full)) {
|
|
snprintf(err, err_sz, "error: no such file: %.200s", path);
|
|
return -1;
|
|
}
|
|
pl->oldc = tool_slurp(pl->full, &pl->olen, EDIT_FILE_MAX);
|
|
if (!pl->oldc) {
|
|
snprintf(err, err_sz, "error: cannot read %.200s (or larger than 8MiB)",
|
|
path);
|
|
return -1;
|
|
}
|
|
char *o2 = NULL, *n2 = NULL;
|
|
size_t cnt = count_occ(pl->oldc, pl->olen, old, on);
|
|
if (cnt == 0 && memchr(old, '\n', on) && strstr(pl->oldc, "\r\n")) {
|
|
size_t o2n, n2n;
|
|
o2 = to_crlf(old, on, &o2n);
|
|
n2 = to_crlf(nw, nwn, &n2n);
|
|
if (o2 && n2) {
|
|
cnt = count_occ(pl->oldc, pl->olen, o2, o2n);
|
|
if (cnt) {
|
|
old = o2;
|
|
on = o2n;
|
|
nw = n2;
|
|
nwn = n2n;
|
|
}
|
|
}
|
|
} else if (cnt && memchr(nw, '\n', nwn) &&
|
|
cst_text_is_crlf(pl->oldc, pl->olen)) {
|
|
size_t n2n;
|
|
n2 = to_crlf(nw, nwn, &n2n);
|
|
if (n2) {
|
|
nw = n2;
|
|
nwn = n2n;
|
|
}
|
|
}
|
|
int rc = 0;
|
|
if (cnt == 0) {
|
|
char near[64];
|
|
near_lines(pl->oldc, pl->olen, old, on, near, sizeof(near));
|
|
if (near[0])
|
|
snprintf(err, err_sz,
|
|
"error: old_string not found in %.200s, but its first line "
|
|
"appears (whitespace aside) at line %s — read_file around "
|
|
"there and copy the exact text, indentation included",
|
|
path, near);
|
|
else
|
|
snprintf(err, err_sz,
|
|
"error: old_string not found in %.200s; it must match the file "
|
|
"exactly (whitespace and indentation included) — re-read the "
|
|
"file (or grep for a distinctive word) and copy the text",
|
|
path);
|
|
rc = -1;
|
|
} else if (cnt > 1 && !all) {
|
|
char where[64];
|
|
occ_lines(pl->oldc, pl->olen, old, on, where, sizeof(where));
|
|
snprintf(err, err_sz,
|
|
"error: old_string matches %zu places in %.200s (lines %s%s); "
|
|
"include more surrounding lines to make it unique, or set "
|
|
"replace_all=true",
|
|
cnt, path, where, cnt > 6 ? ", …" : "");
|
|
rc = -1;
|
|
} else {
|
|
pl->newc = replace_all(pl->oldc, pl->olen, old, on, nw, nwn, all,
|
|
&pl->nlen);
|
|
pl->count = all ? cnt : 1;
|
|
if (!pl->newc) {
|
|
snprintf(err, err_sz, "error: oom");
|
|
rc = -1;
|
|
}
|
|
}
|
|
free(o2);
|
|
free(n2);
|
|
return rc;
|
|
}
|
|
|
|
static void edit_free(edit_plan_t *pl) {
|
|
free(pl->oldc);
|
|
free(pl->newc);
|
|
}
|
|
|
|
int cst_tool_edit_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
edit_plan_t *pl = malloc(sizeof(*pl));
|
|
if (!pl)
|
|
return tool_set_result(r, "error: oom", 1);
|
|
char err[512];
|
|
if (plan_edit(ctx, c, pl, err, sizeof(err)) != 0) {
|
|
edit_free(pl);
|
|
free(pl);
|
|
return tool_set_result(r, err, 1);
|
|
}
|
|
const char *path = tool_arg(c, "path");
|
|
if (tool_write_whole(pl->full, pl->newc, pl->nlen) != 0) {
|
|
edit_free(pl);
|
|
free(pl);
|
|
return tool_set_resultf(r, 1, "error: cannot write %.200s", path);
|
|
}
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "edited %s (%zu replacement%s)\n", path, pl->count,
|
|
pl->count == 1 ? "" : "s");
|
|
tool_append_diff(&b, path, pl->oldc, pl->olen, pl->newc, pl->nlen);
|
|
edit_free(pl);
|
|
free(pl);
|
|
return tool_take_buf(r, &b, 0);
|
|
}
|
|
|
|
int cst_preview_edit_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
char **out) {
|
|
edit_plan_t *pl = malloc(sizeof(*pl));
|
|
if (!pl) {
|
|
*out = strdup("error: oom");
|
|
return -1;
|
|
}
|
|
char err[512];
|
|
int rc = plan_edit(ctx, c, pl, err, sizeof(err));
|
|
if (rc != 0) {
|
|
*out = strdup(err);
|
|
} else {
|
|
cst_buf_t b = {0};
|
|
tool_append_diff(&b, tool_arg(c, "path"), pl->oldc, pl->olen, pl->newc,
|
|
pl->nlen);
|
|
*out = cst_buf_steal(&b);
|
|
}
|
|
edit_free(pl);
|
|
free(pl);
|
|
return rc;
|
|
}
|
|
|
|
/* --- delete_file / move_file --------------------------------------------- */
|
|
|
|
static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) {
|
|
char root[OS_PATH_MAX], joined[OS_PATH_MAX];
|
|
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
|
|
return 1;
|
|
if (os_path_is_abs(path))
|
|
snprintf(joined, sizeof(joined), "%s", path);
|
|
else if (os_path_join(joined, sizeof(joined), root, path) != 0)
|
|
return 1;
|
|
os_stat_t st;
|
|
if (os_lstat(joined, &st) == 0 && st.is_link)
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
int cst_preview_delete_file(const cst_tool_ctx_t *ctx,
|
|
const ctl_tool_call_t *c, char **out) {
|
|
const char *path = tool_arg(c, "path");
|
|
char full[OS_PATH_MAX];
|
|
if (!path || resolve(ctx, path, full, sizeof(full)) != 0 ||
|
|
!is_regular(full)) {
|
|
*out = strdup("error: no such file");
|
|
return -1;
|
|
}
|
|
size_t n = 0;
|
|
char *old = tool_slurp(full, &n, READ_FILE_MAX);
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "delete %s (%zu bytes)\n", path, n);
|
|
if (old)
|
|
tool_append_diff(&b, path, old, n, "", 0);
|
|
free(old);
|
|
*out = cst_buf_steal(&b);
|
|
return 0;
|
|
}
|
|
|
|
int cst_tool_delete_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
const char *path = tool_arg(c, "path");
|
|
char full[OS_PATH_MAX];
|
|
if (!path || !path[0])
|
|
return tool_set_result(r, "error: missing path", 1);
|
|
if (reject_symlink(ctx, path))
|
|
return tool_set_resultf(r, 1, "error: %.200s is a symlink; not deleted",
|
|
path);
|
|
if (resolve(ctx, path, full, sizeof(full)) != 0)
|
|
return tool_set_resultf(r, 1, "error: path outside workspace: %.200s",
|
|
path);
|
|
if (is_dir(full))
|
|
return tool_set_resultf(r, 1,
|
|
"error: %.200s is a directory (delete_file only "
|
|
"removes files)",
|
|
path);
|
|
if (!is_regular(full))
|
|
return tool_set_resultf(r, 1, "error: no such file: %.200s", path);
|
|
size_t n = 0;
|
|
char *old = tool_slurp(full, &n, READ_FILE_MAX);
|
|
if (remove(full) != 0) {
|
|
free(old);
|
|
return tool_set_resultf(r, 1, "error: cannot delete %.200s: %s", path,
|
|
strerror(errno));
|
|
}
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "deleted %s (%zu bytes)\n", path, n);
|
|
if (old)
|
|
tool_append_diff(&b, path, old, n, "", 0);
|
|
free(old);
|
|
return tool_take_buf(r, &b, 0);
|
|
}
|
|
|
|
static int plan_move(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
char *src, char *dst, char *err, size_t err_sz,
|
|
int mkdirs) {
|
|
const char *from = tool_arg(c, "from");
|
|
const char *to = tool_arg(c, "to");
|
|
if (!from || !from[0] || !to || !to[0]) {
|
|
snprintf(err, err_sz, "error: need from and to");
|
|
return -1;
|
|
}
|
|
if (reject_symlink(ctx, from)) {
|
|
snprintf(err, err_sz, "error: %.200s is a symlink", from);
|
|
return -1;
|
|
}
|
|
if (resolve(ctx, from, src, OS_PATH_MAX) != 0 || !is_regular(src)) {
|
|
snprintf(err, err_sz, "error: no such file: %.200s", from);
|
|
return -1;
|
|
}
|
|
if (mkdirs)
|
|
tool_ensure_parent_dirs(ctx->workspace_root, to);
|
|
if (resolve(ctx, to, dst, OS_PATH_MAX) != 0) {
|
|
if (!mkdirs) {
|
|
snprintf(dst, OS_PATH_MAX, "%s", to);
|
|
return 0;
|
|
}
|
|
snprintf(err, err_sz, "error: destination outside workspace: %.200s", to);
|
|
return -1;
|
|
}
|
|
struct stat st;
|
|
if (stat(dst, &st) == 0) {
|
|
snprintf(err, err_sz, "error: destination exists: %.200s", to);
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int cst_preview_move_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
char **out) {
|
|
char src[OS_PATH_MAX], dst[OS_PATH_MAX], err[512];
|
|
if (plan_move(ctx, c, src, dst, err, sizeof(err), 0) != 0) {
|
|
*out = strdup(err);
|
|
return -1;
|
|
}
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "move %s → %s\n", tool_arg(c, "from"), tool_arg(c, "to"));
|
|
*out = cst_buf_steal(&b);
|
|
return 0;
|
|
}
|
|
|
|
int cst_tool_move_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
char src[OS_PATH_MAX], dst[OS_PATH_MAX], err[512];
|
|
if (plan_move(ctx, c, src, dst, err, sizeof(err), 1) != 0)
|
|
return tool_set_result(r, err, 1);
|
|
if (rename(src, dst) != 0)
|
|
return tool_set_resultf(r, 1, "error: cannot move: %s", strerror(errno));
|
|
return tool_set_resultf(r, 0, "moved %.300s → %.300s", tool_arg(c, "from"),
|
|
tool_arg(c, "to"));
|
|
}
|
|
|
|
/* --- directory walking (list_dir / glob / grep) -------------------------- */
|
|
|
|
static const char *const SKIP_DIRS[] = {
|
|
".git", ".hg", ".svn", "node_modules", ".agent", "__pycache__",
|
|
".venv", ".cache", ".idea", ".vscode", ".mypy_cache", ".pytest_cache"};
|
|
|
|
static int skip_dir_name(const char *name) {
|
|
for (size_t i = 0; i < sizeof(SKIP_DIRS) / sizeof(SKIP_DIRS[0]); i++) {
|
|
if (strcmp(name, SKIP_DIRS[i]) == 0)
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Root .gitignore, simple rules only: `name`, `*.o`, `dir/`, `/path`. */
|
|
typedef struct {
|
|
char pat[256];
|
|
int dir_only;
|
|
int anchored; /* contains '/': match the relative path */
|
|
} ign_rule_t;
|
|
|
|
typedef struct {
|
|
ign_rule_t *v;
|
|
int n;
|
|
} ignore_t;
|
|
|
|
static void ignore_load(const char *root, ignore_t *ig) {
|
|
memset(ig, 0, sizeof(*ig));
|
|
char p[OS_PATH_MAX];
|
|
if (os_path_join(p, sizeof(p), root, ".gitignore") != 0)
|
|
return;
|
|
FILE *fp = fopen(p, "rb");
|
|
if (!fp)
|
|
return;
|
|
char line[512];
|
|
int cap = 0;
|
|
while (fgets(line, sizeof(line), fp)) {
|
|
size_t n = strlen(line);
|
|
while (n && (line[n - 1] == '\n' || line[n - 1] == '\r' ||
|
|
line[n - 1] == ' '))
|
|
line[--n] = 0;
|
|
if (!n || line[0] == '#' || line[0] == '!')
|
|
continue;
|
|
if (ig->n == cap) {
|
|
cap = cap ? cap * 2 : 16;
|
|
ign_rule_t *nv = realloc(ig->v, (size_t)cap * sizeof(*nv));
|
|
if (!nv)
|
|
break;
|
|
ig->v = nv;
|
|
}
|
|
ign_rule_t *r = &ig->v[ig->n];
|
|
memset(r, 0, sizeof(*r));
|
|
char *s = line;
|
|
if (n && s[n - 1] == '/') {
|
|
r->dir_only = 1;
|
|
s[--n] = 0;
|
|
}
|
|
if (s[0] == '/') {
|
|
s++;
|
|
r->anchored = 1;
|
|
}
|
|
if (strchr(s, '/'))
|
|
r->anchored = 1;
|
|
if (!s[0])
|
|
continue;
|
|
snprintf(r->pat, sizeof(r->pat), "%.255s", s);
|
|
ig->n++;
|
|
}
|
|
fclose(fp);
|
|
}
|
|
|
|
static int ignored(const ignore_t *ig, const char *rel, const char *name,
|
|
int isdir) {
|
|
for (int i = 0; i < ig->n; i++) {
|
|
const ign_rule_t *r = &ig->v[i];
|
|
if (r->dir_only && !isdir)
|
|
continue;
|
|
if (cst_fnmatch(r->pat, r->anchored ? rel : name, 0) == 0)
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
typedef struct walker walker_t;
|
|
/* Return non-zero to stop the walk. */
|
|
typedef int (*walk_fn)(walker_t *w, const char *rel, const char *full,
|
|
const os_stat_t *st, int depth);
|
|
|
|
struct walker {
|
|
const char *root; /* workspace realpath */
|
|
ignore_t ig;
|
|
walk_fn fn;
|
|
void *ud;
|
|
int max_depth;
|
|
int stop;
|
|
};
|
|
|
|
static int name_cmp(const void *a, const void *b) {
|
|
return strcmp(*(char *const *)a, *(char *const *)b);
|
|
}
|
|
|
|
static void walk_dir(walker_t *w, const char *rel, int depth) {
|
|
if (w->stop)
|
|
return;
|
|
char dirfull[OS_PATH_MAX];
|
|
if (rel[0])
|
|
snprintf(dirfull, sizeof(dirfull), "%s/%s", w->root, rel);
|
|
else
|
|
snprintf(dirfull, sizeof(dirfull), "%s", w->root);
|
|
os_dir_t *d = os_dir_open(dirfull);
|
|
if (!d)
|
|
return;
|
|
char **names = NULL;
|
|
int n = 0, cap = 0;
|
|
const os_dirent_t *de;
|
|
while ((de = os_dir_next(d)) != NULL) {
|
|
if (n == cap) {
|
|
cap = cap ? cap * 2 : 32;
|
|
char **nv = realloc(names, (size_t)cap * sizeof(char *));
|
|
if (!nv)
|
|
break;
|
|
names = nv;
|
|
}
|
|
names[n] = strdup(de->name);
|
|
if (names[n])
|
|
n++;
|
|
}
|
|
os_dir_close(d);
|
|
qsort(names, (size_t)n, sizeof(char *), name_cmp);
|
|
for (int i = 0; i < n && !w->stop; i++) {
|
|
char crel[OS_PATH_MAX], cfull[OS_PATH_MAX];
|
|
int k1 = rel[0] ? snprintf(crel, sizeof(crel), "%s/%s", rel, names[i])
|
|
: snprintf(crel, sizeof(crel), "%s", names[i]);
|
|
int k2 = snprintf(cfull, sizeof(cfull), "%s/%s", w->root, crel);
|
|
if (k1 >= (int)sizeof(crel) || k2 >= (int)sizeof(cfull))
|
|
continue; /* path too long */
|
|
os_stat_t st;
|
|
if (os_lstat(cfull, &st) != 0)
|
|
continue;
|
|
int isdir = st.is_dir;
|
|
if (ignored(&w->ig, crel, names[i], isdir))
|
|
continue;
|
|
if (w->fn(w, crel, cfull, &st, depth))
|
|
w->stop = 1;
|
|
if (isdir && !skip_dir_name(names[i]) && depth + 1 < w->max_depth)
|
|
walk_dir(w, crel, depth + 1);
|
|
}
|
|
for (int i = 0; i < n; i++)
|
|
free(names[i]);
|
|
free(names);
|
|
}
|
|
|
|
/* Resolve an optional directory argument to a workspace-relative prefix. */
|
|
static int start_rel(const cst_tool_ctx_t *ctx, const char *path,
|
|
const char *root, char *rel, size_t rel_sz,
|
|
char *err, size_t err_sz) {
|
|
rel[0] = 0;
|
|
if (!path || !path[0] || strcmp(path, ".") == 0 || strcmp(path, "./") == 0)
|
|
return 0;
|
|
char full[OS_PATH_MAX];
|
|
if (resolve(ctx, path, full, sizeof(full)) != 0) {
|
|
snprintf(err, err_sz, "error: path outside workspace: %.200s", path);
|
|
return -1;
|
|
}
|
|
struct stat st;
|
|
if (stat(full, &st) != 0) {
|
|
snprintf(err, err_sz, "error: no such path: %.200s", path);
|
|
return -1;
|
|
}
|
|
snprintf(rel, rel_sz, "%s", tool_rel(root, full));
|
|
if (strcmp(rel, ".") == 0)
|
|
rel[0] = 0;
|
|
return S_ISDIR(st.st_mode) ? 0 : 1;
|
|
}
|
|
|
|
/* list_dir */
|
|
#define LIST_COUNT_MAX 100000
|
|
|
|
typedef struct {
|
|
cst_buf_t b;
|
|
int count;
|
|
int more; /* entries past LIST_MAX (counted, not shown) */
|
|
int truncated; /* stopped counting */
|
|
} list_ud_t;
|
|
|
|
/* Entries directly inside a directory (for the depth boundary). */
|
|
static int dir_entries(const char *full) {
|
|
os_dir_t *d = os_dir_open(full);
|
|
if (!d)
|
|
return -1;
|
|
int n = 0;
|
|
while (os_dir_next(d))
|
|
n++;
|
|
os_dir_close(d);
|
|
return n;
|
|
}
|
|
|
|
static int list_cb(walker_t *w, const char *rel, const char *full,
|
|
const os_stat_t *st, int depth) {
|
|
list_ud_t *u = w->ud;
|
|
if (u->count >= LIST_MAX) {
|
|
if (++u->more >= LIST_COUNT_MAX) {
|
|
u->truncated = 1;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
const char *name = strrchr(rel, '/');
|
|
name = name ? name + 1 : rel;
|
|
for (int i = 0; i < depth; i++)
|
|
cst_buf_puts(&u->b, " ");
|
|
if (st->is_dir) {
|
|
int k = -1;
|
|
if (skip_dir_name(name))
|
|
cst_buf_printf(&u->b, "%s/ (skipped)\n", name);
|
|
else if (depth + 1 >= w->max_depth && (k = dir_entries(full)) > 0)
|
|
cst_buf_printf(&u->b, "%s/ (%d entr%s)\n", name, k,
|
|
k == 1 ? "y" : "ies");
|
|
else
|
|
cst_buf_printf(&u->b, "%s/%s\n", name, k == 0 ? " (empty)" : "");
|
|
} else if (st->is_link)
|
|
cst_buf_printf(&u->b, "%s@\n", name);
|
|
else
|
|
cst_buf_printf(&u->b, "%s (%lld B)\n", name, st->size);
|
|
u->count++;
|
|
return 0;
|
|
}
|
|
|
|
int cst_tool_list_dir(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
char root[OS_PATH_MAX], rel[OS_PATH_MAX], err[512];
|
|
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
|
|
return tool_set_result(r, "error: bad workspace", 1);
|
|
int k = start_rel(ctx, tool_arg(c, "path"), root, rel, sizeof(rel), err,
|
|
sizeof(err));
|
|
if (k < 0)
|
|
return tool_set_result(r, err, 1);
|
|
if (k == 1)
|
|
return tool_set_resultf(r, 1, "error: %.200s is a file (use read_file)",
|
|
rel);
|
|
long depth = tool_arg_long(c, "depth", 2);
|
|
if (depth < 1)
|
|
depth = 1;
|
|
if (depth > 6)
|
|
depth = 6;
|
|
walker_t w;
|
|
memset(&w, 0, sizeof(w));
|
|
w.root = root;
|
|
ignore_load(root, &w.ig);
|
|
list_ud_t u;
|
|
memset(&u, 0, sizeof(u));
|
|
w.fn = list_cb;
|
|
w.ud = &u;
|
|
w.max_depth = (int)depth;
|
|
cst_buf_printf(&u.b, "%s/\n", rel[0] ? rel : ".");
|
|
walk_dir(&w, rel, 0);
|
|
free(w.ig.v);
|
|
if (u.count == 0)
|
|
cst_buf_puts(&u.b, "(empty)\n");
|
|
if (u.more)
|
|
cst_buf_printf(&u.b, "… %s%d more entries not shown (list a subdirectory "
|
|
"or use a smaller depth)\n",
|
|
u.truncated ? "over " : "", u.more);
|
|
return tool_take_buf(r, &u.b, 0);
|
|
}
|
|
|
|
/* glob */
|
|
static int seg_match(const char *pat, const char *path) {
|
|
/* pat and path are '/'-separated; "**" spans any number of segments */
|
|
if (!*pat)
|
|
return !*path;
|
|
const char *ps = strchr(pat, '/');
|
|
size_t pl = ps ? (size_t)(ps - pat) : strlen(pat);
|
|
if (pl == 2 && pat[0] == '*' && pat[1] == '*') {
|
|
const char *rest = ps ? ps + 1 : "";
|
|
if (!*rest)
|
|
return 1;
|
|
for (const char *p = path;; ) {
|
|
if (seg_match(rest, p))
|
|
return 1;
|
|
const char *sl = strchr(p, '/');
|
|
if (!sl)
|
|
return 0;
|
|
p = sl + 1;
|
|
}
|
|
}
|
|
if (!*path)
|
|
return 0;
|
|
const char *ss = strchr(path, '/');
|
|
size_t sl = ss ? (size_t)(ss - path) : strlen(path);
|
|
char pseg[256], sseg[256];
|
|
if (pl >= sizeof(pseg) || sl >= sizeof(sseg))
|
|
return 0;
|
|
memcpy(pseg, pat, pl);
|
|
pseg[pl] = 0;
|
|
memcpy(sseg, path, sl);
|
|
sseg[sl] = 0;
|
|
if (cst_fnmatch(pseg, sseg, CST_FNM_PERIOD) != 0)
|
|
return 0;
|
|
if (!ps)
|
|
return !ss;
|
|
if (!ss)
|
|
return 0;
|
|
return seg_match(ps + 1, ss + 1);
|
|
}
|
|
|
|
typedef struct {
|
|
const char *pattern;
|
|
const char *base; /* rel prefix of the search dir ("" = root) */
|
|
int basename_only;
|
|
char **hits;
|
|
double *mtimes;
|
|
int n, cap;
|
|
int truncated;
|
|
} glob_ud_t;
|
|
|
|
static int glob_cb(walker_t *w, const char *rel, const char *full,
|
|
const os_stat_t *st, int depth) {
|
|
(void)full;
|
|
(void)depth;
|
|
glob_ud_t *u = w->ud;
|
|
if (!st->is_reg)
|
|
return 0;
|
|
const char *sub = rel;
|
|
size_t bl = strlen(u->base);
|
|
if (bl) {
|
|
if (strncmp(rel, u->base, bl) != 0 || rel[bl] != '/')
|
|
return 0;
|
|
sub = rel + bl + 1;
|
|
}
|
|
const char *name = strrchr(sub, '/');
|
|
name = name ? name + 1 : sub;
|
|
int ok = u->basename_only ? cst_fnmatch(u->pattern, name, CST_FNM_PERIOD) == 0
|
|
: seg_match(u->pattern, sub);
|
|
if (!ok)
|
|
return 0;
|
|
if (u->n == u->cap) {
|
|
u->cap = u->cap ? u->cap * 2 : 64;
|
|
char **nh = realloc(u->hits, (size_t)u->cap * sizeof(char *));
|
|
double *nm = realloc(u->mtimes, (size_t)u->cap * sizeof(double));
|
|
if (nh)
|
|
u->hits = nh;
|
|
if (nm)
|
|
u->mtimes = nm;
|
|
if (!nh || !nm)
|
|
return 1;
|
|
}
|
|
u->hits[u->n] = strdup(rel);
|
|
u->mtimes[u->n] = st->mtime;
|
|
if (u->hits[u->n])
|
|
u->n++;
|
|
if (u->n >= GLOB_MAX * 20) {
|
|
u->truncated = 1;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
typedef struct {
|
|
const char *path;
|
|
double mtime;
|
|
} glob_hit_t;
|
|
|
|
static int hit_by_mtime(const void *a, const void *b) {
|
|
const glob_hit_t *x = a, *y = b;
|
|
if (x->mtime != y->mtime)
|
|
return x->mtime < y->mtime ? 1 : -1;
|
|
return strcmp(x->path, y->path);
|
|
}
|
|
|
|
int cst_tool_glob(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
const char *pattern = tool_arg(c, "pattern");
|
|
if (!pattern || !pattern[0])
|
|
return tool_set_result(r, "error: missing pattern", 1);
|
|
char root[OS_PATH_MAX], rel[OS_PATH_MAX], err[512];
|
|
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
|
|
return tool_set_result(r, "error: bad workspace", 1);
|
|
int k = start_rel(ctx, tool_arg(c, "path"), root, rel, sizeof(rel), err,
|
|
sizeof(err));
|
|
if (k < 0)
|
|
return tool_set_result(r, err, 1);
|
|
while (pattern[0] == '.' && pattern[1] == '/')
|
|
pattern += 2;
|
|
walker_t w;
|
|
memset(&w, 0, sizeof(w));
|
|
w.root = root;
|
|
ignore_load(root, &w.ig);
|
|
glob_ud_t u;
|
|
memset(&u, 0, sizeof(u));
|
|
u.pattern = pattern;
|
|
u.base = rel;
|
|
u.basename_only = strchr(pattern, '/') == NULL;
|
|
w.fn = glob_cb;
|
|
w.ud = &u;
|
|
w.max_depth = 64;
|
|
walk_dir(&w, rel, 0);
|
|
free(w.ig.v);
|
|
|
|
cst_buf_t b = {0};
|
|
glob_hit_t *hs = malloc((size_t)(u.n ? u.n : 1) * sizeof(*hs));
|
|
if (hs) {
|
|
for (int i = 0; i < u.n; i++) {
|
|
hs[i].path = u.hits[i];
|
|
hs[i].mtime = u.mtimes[i];
|
|
}
|
|
qsort(hs, (size_t)u.n, sizeof(*hs), hit_by_mtime);
|
|
for (int i = 0; i < u.n && i < GLOB_MAX; i++)
|
|
cst_buf_printf(&b, "%s\n", hs[i].path);
|
|
}
|
|
free(hs);
|
|
if (u.n == 0)
|
|
cst_buf_printf(&b, "(no files match %s)\n", pattern);
|
|
else if (u.n > GLOB_MAX || u.truncated)
|
|
cst_buf_printf(&b, "… %s%d more files (showing the %d newest of %s%d; "
|
|
"narrow the pattern or path)\n",
|
|
u.truncated ? "over " : "", u.n - GLOB_MAX, GLOB_MAX,
|
|
u.truncated ? "over " : "", u.n);
|
|
for (int i = 0; i < u.n; i++)
|
|
free(u.hits[i]);
|
|
free(u.hits);
|
|
free(u.mtimes);
|
|
return tool_take_buf(r, &b, 0);
|
|
}
|
|
|
|
/* Workspace file list for UI completion (same ignore rules as glob). */
|
|
typedef struct {
|
|
char **v;
|
|
int n, max;
|
|
} files_ud_t;
|
|
|
|
static int files_cb(walker_t *w, const char *rel, const char *full,
|
|
const os_stat_t *st, int depth) {
|
|
(void)full;
|
|
(void)depth;
|
|
files_ud_t *u = w->ud;
|
|
if (!st->is_reg)
|
|
return 0;
|
|
if (u->n >= u->max)
|
|
return 1;
|
|
u->v[u->n] = strdup(rel);
|
|
if (u->v[u->n])
|
|
u->n++;
|
|
return 0;
|
|
}
|
|
|
|
int cst_workspace_files(const char *ws, char ***out, int max) {
|
|
*out = NULL;
|
|
char root[OS_PATH_MAX];
|
|
if (max <= 0 || os_realpath(ws, root, sizeof(root)) != 0)
|
|
return 0;
|
|
files_ud_t u = {calloc((size_t)max, sizeof(char *)), 0, max};
|
|
if (!u.v)
|
|
return 0;
|
|
walker_t w;
|
|
memset(&w, 0, sizeof(w));
|
|
w.root = root;
|
|
ignore_load(root, &w.ig);
|
|
w.fn = files_cb;
|
|
w.ud = &u;
|
|
w.max_depth = 16;
|
|
walk_dir(&w, "", 0);
|
|
free(w.ig.v);
|
|
*out = u.v;
|
|
return u.n;
|
|
}
|
|
|
|
/* grep: matches grouped by file, "N matches in F files" summary. */
|
|
#define GREP_PER_FILE 20
|
|
#define GREP_COUNT_MAX 5000
|
|
|
|
typedef struct {
|
|
regex_t re;
|
|
const char *glob;
|
|
int context;
|
|
cst_buf_t b;
|
|
int matches; /* counted (up to GREP_COUNT_MAX) */
|
|
int shown;
|
|
int files; /* text files searched */
|
|
int hit_files; /* files with a match */
|
|
int truncated; /* stopped counting */
|
|
} grep_ud_t;
|
|
|
|
static void put_line(cst_buf_t *b, long ln, char sep, const char *s,
|
|
size_t n) {
|
|
if (n && s[n - 1] == '\n')
|
|
n--;
|
|
if (n && s[n - 1] == '\r')
|
|
n--;
|
|
int cut = 0;
|
|
if (n > GREP_LINE_MAX) {
|
|
n = cst_utf8_floor(s, GREP_LINE_MAX);
|
|
cut = 1;
|
|
}
|
|
cst_buf_printf(b, "%ld%c", ln, sep);
|
|
cst_buf_append(b, s, n);
|
|
cst_buf_puts(b, cut ? " …\n" : "\n");
|
|
}
|
|
|
|
static int line_hit(grep_ud_t *u, const char *s, size_t n) {
|
|
char tmp[GREP_LINE_MAX * 8];
|
|
size_t m = n < sizeof(tmp) - 1 ? n : sizeof(tmp) - 1;
|
|
memcpy(tmp, s, m);
|
|
tmp[m] = 0;
|
|
if (m && tmp[m - 1] == '\n')
|
|
tmp[--m] = 0;
|
|
if (m && tmp[m - 1] == '\r')
|
|
tmp[--m] = 0;
|
|
return regexec(&u->re, tmp, 0, NULL, 0) == 0;
|
|
}
|
|
|
|
static void grep_file(grep_ud_t *u, const char *rel, const char *full) {
|
|
size_t n = 0;
|
|
char *buf = tool_slurp(full, &n, READ_FILE_MAX);
|
|
if (!buf)
|
|
return;
|
|
if (tool_is_binary(buf, n)) {
|
|
free(buf);
|
|
return;
|
|
}
|
|
u->files++;
|
|
/* line table */
|
|
int nl = 0, cap = 256;
|
|
const char **ls = malloc((size_t)cap * sizeof(char *));
|
|
size_t *ln = malloc((size_t)cap * sizeof(size_t));
|
|
if (!ls || !ln) {
|
|
free(ls);
|
|
free(ln);
|
|
free(buf);
|
|
return;
|
|
}
|
|
for (size_t i = 0; i < n;) {
|
|
const char *e = memchr(buf + i, '\n', n - i);
|
|
size_t end = e ? (size_t)(e - buf) + 1 : n;
|
|
if (nl == cap) {
|
|
cap *= 2;
|
|
const char **a = realloc(ls, (size_t)cap * sizeof(char *));
|
|
size_t *bb = realloc(ln, (size_t)cap * sizeof(size_t));
|
|
if (a)
|
|
ls = a;
|
|
if (bb)
|
|
ln = bb;
|
|
if (!a || !bb)
|
|
break;
|
|
}
|
|
ls[nl] = buf + i;
|
|
ln[nl] = end - i;
|
|
nl++;
|
|
i = end;
|
|
}
|
|
cst_buf_t fb = {0};
|
|
int fm = 0, fshown = 0, last_printed = -1;
|
|
for (int i = 0; i < nl; i++) {
|
|
if (!line_hit(u, ls[i], ln[i]))
|
|
continue;
|
|
fm++;
|
|
if (++u->matches >= GREP_COUNT_MAX)
|
|
u->truncated = 1;
|
|
if (fshown < GREP_PER_FILE && u->shown < GREP_MAX_MATCHES) {
|
|
int from = i - u->context < 0 ? 0 : i - u->context;
|
|
if (from <= last_printed)
|
|
from = last_printed + 1;
|
|
else if (u->context && last_printed >= 0)
|
|
cst_buf_puts(&fb, "--\n");
|
|
for (int k = from; k < i; k++)
|
|
put_line(&fb, k + 1, '-', ls[k], ln[k]);
|
|
put_line(&fb, i + 1, ':', ls[i], ln[i]);
|
|
last_printed = i;
|
|
for (int k = i + 1; k <= i + u->context && k < nl; k++) {
|
|
if (line_hit(u, ls[k], ln[k]))
|
|
break; /* printed by the main loop */
|
|
put_line(&fb, k + 1, '-', ls[k], ln[k]);
|
|
last_printed = k;
|
|
}
|
|
fshown++;
|
|
u->shown++;
|
|
}
|
|
if (u->truncated)
|
|
break;
|
|
}
|
|
if (fm) {
|
|
u->hit_files++;
|
|
if (fm == 1)
|
|
cst_buf_printf(&u->b, "%s\n", rel);
|
|
else
|
|
cst_buf_printf(&u->b, "%s (%d matches)\n", rel, fm);
|
|
if (fb.buf)
|
|
cst_buf_append(&u->b, fb.buf, fb.len);
|
|
if (fm > fshown)
|
|
cst_buf_printf(&u->b, "… %d more in this file\n", fm - fshown);
|
|
}
|
|
cst_buf_free(&fb);
|
|
free(ls);
|
|
free(ln);
|
|
free(buf);
|
|
}
|
|
|
|
static int grep_cb(walker_t *w, const char *rel, const char *full,
|
|
const os_stat_t *st, int depth) {
|
|
(void)depth;
|
|
grep_ud_t *u = w->ud;
|
|
if (!st->is_reg || st->size > READ_FILE_MAX)
|
|
return 0;
|
|
if (u->glob) {
|
|
const char *name = strrchr(rel, '/');
|
|
name = name ? name + 1 : rel;
|
|
int ok = strchr(u->glob, '/') ? cst_fnmatch(u->glob, rel, 0) == 0
|
|
: cst_fnmatch(u->glob, name, 0) == 0;
|
|
if (!ok)
|
|
return 0;
|
|
}
|
|
grep_file(u, rel, full);
|
|
return u->truncated || u->files >= GREP_MAX_FILES;
|
|
}
|
|
|
|
/* Translate common PCRE shorthands models use into POSIX ERE. */
|
|
static char *pcre_to_ere(const char *p) {
|
|
cst_buf_t b = {0};
|
|
for (size_t i = 0; p[i]; i++) {
|
|
if (p[i] == '\\' && p[i + 1]) {
|
|
const char *rep = NULL;
|
|
switch (p[i + 1]) {
|
|
case 'd': rep = "[0-9]"; break;
|
|
case 'D': rep = "[^0-9]"; break;
|
|
case 'w': rep = "[A-Za-z0-9_]"; break;
|
|
case 'W': rep = "[^A-Za-z0-9_]"; break;
|
|
case 's': rep = "[[:space:]]"; break;
|
|
case 'S': rep = "[^[:space:]]"; break;
|
|
default: break;
|
|
}
|
|
if (rep) {
|
|
cst_buf_puts(&b, rep);
|
|
i++;
|
|
continue;
|
|
}
|
|
cst_buf_append(&b, p + i, 2);
|
|
i++;
|
|
continue;
|
|
}
|
|
cst_buf_append(&b, p + i, 1);
|
|
}
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
int cst_tool_grep(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r) {
|
|
const char *pattern = tool_arg(c, "pattern");
|
|
if (!pattern || !pattern[0])
|
|
return tool_set_result(r, "error: missing pattern", 1);
|
|
char root[OS_PATH_MAX], rel[OS_PATH_MAX], err[512];
|
|
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
|
|
return tool_set_result(r, "error: bad workspace", 1);
|
|
int k = start_rel(ctx, tool_arg(c, "path"), root, rel, sizeof(rel), err,
|
|
sizeof(err));
|
|
if (k < 0)
|
|
return tool_set_result(r, err, 1);
|
|
|
|
grep_ud_t u;
|
|
memset(&u, 0, sizeof(u));
|
|
char *ere = pcre_to_ere(pattern);
|
|
int flags = REG_EXTENDED | REG_NOSUB;
|
|
if (tool_arg_bool(c, "ignore_case", 0))
|
|
flags |= REG_ICASE;
|
|
int rc = ere ? regcomp(&u.re, ere, flags) : -1;
|
|
free(ere);
|
|
if (rc != 0) {
|
|
char msg[200] = "out of memory";
|
|
if (rc > 0)
|
|
regerror(rc, &u.re, msg, sizeof(msg));
|
|
return tool_set_resultf(r, 1, "error: bad regex: %s", msg);
|
|
}
|
|
const char *g = tool_arg(c, "glob");
|
|
u.glob = g && g[0] ? g : NULL;
|
|
long ctxl = tool_arg_long(c, "context", 0);
|
|
u.context = ctxl < 0 ? 0 : ctxl > 5 ? 5 : (int)ctxl;
|
|
|
|
if (k == 1) {
|
|
char full[OS_PATH_MAX];
|
|
if (snprintf(full, sizeof(full), "%s/%s", root, rel) < (int)sizeof(full))
|
|
grep_file(&u, rel, full);
|
|
} else {
|
|
walker_t w;
|
|
memset(&w, 0, sizeof(w));
|
|
w.root = root;
|
|
ignore_load(root, &w.ig);
|
|
w.fn = grep_cb;
|
|
w.ud = &u;
|
|
w.max_depth = 64;
|
|
walk_dir(&w, rel, 0);
|
|
free(w.ig.v);
|
|
}
|
|
regfree(&u.re);
|
|
if (u.matches == 0) {
|
|
cst_buf_free(&u.b);
|
|
cst_buf_printf(&u.b, "(no matches in %d file%s)\n", u.files,
|
|
u.files == 1 ? "" : "s");
|
|
return tool_take_buf(r, &u.b, 0);
|
|
}
|
|
cst_buf_t out = {0};
|
|
cst_buf_printf(&out, "%s%d match%s in %d file%s", u.truncated ? "over " : "",
|
|
u.matches, u.matches == 1 ? "" : "es", u.hit_files,
|
|
u.hit_files == 1 ? "" : "s");
|
|
if (u.shown < u.matches)
|
|
cst_buf_printf(&out, " (showing %d; narrow with path/glob or a tighter "
|
|
"pattern)",
|
|
u.shown);
|
|
cst_buf_puts(&out, "\n");
|
|
cst_buf_append(&out, u.b.buf, u.b.len);
|
|
cst_buf_free(&u.b);
|
|
return tool_take_buf(r, &out, 0);
|
|
}
|
|
|
|
|