forked from WHC/Hermit
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
1494 lines
44 KiB
C
1494 lines
44 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);
|
|
if (!cst_attach_is_image(a)) {
|
|
cst_attach_free(a);
|
|
return tool_set_resultf(
|
|
r, 1,
|
|
"error: %.200s is not an image; a text file or PDF reaches the model "
|
|
"through /attach <path> (a PDF becomes its text layer)",
|
|
path);
|
|
}
|
|
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;
|
|
}
|
|
|
|
/* --- deliver_file -------------------------------------------------------- */
|
|
|
|
/* The file IS the answer: copy it into the session store and hand it to the
|
|
* person as a card the front end can open or share. It is marked for_user, so
|
|
* the model never sees the copy again -- the result line has to say what was
|
|
* delivered. */
|
|
int cst_tool_deliver_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");
|
|
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 file 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_artifact(session, full, tool_arg(c, "title"), err,
|
|
sizeof(err));
|
|
if (!a)
|
|
return tool_set_resultf(r, 1, "error: cannot deliver %.200s: %.200s", path,
|
|
err);
|
|
char human[32];
|
|
if (a->bytes >= 1024 * 1024)
|
|
snprintf(human, sizeof(human), "%.1f MB",
|
|
(double)a->bytes / (1024.0 * 1024.0));
|
|
else if (a->bytes >= 1024)
|
|
snprintf(human, sizeof(human), "%.0f KB", (double)a->bytes / 1024.0);
|
|
else
|
|
snprintf(human, sizeof(human), "%lld B", a->bytes);
|
|
cst_buf_t out = {0};
|
|
cst_buf_printf(&out, "delivered %.120s (%s, %s)\n", a->name, a->mime, human);
|
|
cst_buf_puts(&out,
|
|
"(the user sees it as a file card they can open or share; "
|
|
"you will not be shown its bytes again)\n");
|
|
/* After tool_take_buf: it clears the result, and view_image does the same. */
|
|
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);
|
|
}
|
|
|
|
|