mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-31 07:33:32 +08:00
968 lines
42 KiB
C
968 lines
42 KiB
C
/*
|
|
* atlas_api.c — CBM Atlas data services: the Modules tree and the Symbol
|
|
* bundle. Pure JSON producers over an open store (handlers stay in
|
|
* http_server.c; tests call these directly).
|
|
*/
|
|
#include "foundation/constants.h"
|
|
#include "foundation/json_writer.h"
|
|
#include "ui/atlas.h"
|
|
#include "foundation/hash_table.h"
|
|
|
|
#include <sqlite3.h>
|
|
#include <yyjson/yyjson.h>
|
|
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* ── Small helpers ────────────────────────────────────────────── */
|
|
|
|
static char *aa_strdup(const char *s) {
|
|
if (!s)
|
|
return NULL;
|
|
size_t len = strlen(s) + 1;
|
|
char *copy = cbm_alloc(CBM_MEM_CLASS_OTHER, len);
|
|
if (copy)
|
|
memcpy(copy, s, len);
|
|
return copy;
|
|
}
|
|
|
|
/* Normalize a folder path: "" and "." mean the root; strip trailing '/'. */
|
|
static void aa_norm_path(const char *in, char *out, size_t cap) {
|
|
if (!in || !in[0] || strcmp(in, ".") == 0) {
|
|
out[0] = '\0';
|
|
return;
|
|
}
|
|
snprintf(out, cap, "%s", in);
|
|
size_t len = strlen(out);
|
|
while (len > 0 && out[len - 1] == '/')
|
|
out[--len] = '\0';
|
|
}
|
|
|
|
/* The next path component of `full` under folder `prefix` ("" = root).
|
|
* Returns false when `full` is not under `prefix`. `is_direct_file` is true
|
|
* when `full` names a file directly inside `prefix` (no further '/'). */
|
|
static bool aa_child_component(const char *full, const char *prefix, char *comp, size_t cap,
|
|
bool *is_direct_file) {
|
|
size_t plen = strlen(prefix);
|
|
const char *rest = full;
|
|
if (plen > 0) {
|
|
if (strncmp(full, prefix, plen) != 0 || full[plen] != '/')
|
|
return false;
|
|
rest = full + plen + 1;
|
|
}
|
|
if (!rest[0])
|
|
return false;
|
|
const char *slash = strchr(rest, '/');
|
|
size_t clen = slash ? (size_t)(slash - rest) : strlen(rest);
|
|
if (clen == 0 || clen >= cap)
|
|
return false;
|
|
memcpy(comp, rest, clen);
|
|
comp[clen] = '\0';
|
|
*is_direct_file = slash == NULL;
|
|
return true;
|
|
}
|
|
|
|
/* ── /api/tree ────────────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
char *name;
|
|
long long symbols;
|
|
int files;
|
|
int missed;
|
|
int region; /* dominant region id (majority by file), -1 unknown */
|
|
int region_best; /* votes for the dominant region so far */
|
|
bool is_file;
|
|
} aa_tree_child_t;
|
|
|
|
enum { AA_TREE_MAX_CHILDREN = 4096 };
|
|
|
|
typedef struct {
|
|
aa_tree_child_t *items;
|
|
int count;
|
|
int cap;
|
|
CBMHashTable *by_name; /* name → index+1 (keys borrowed from items) */
|
|
int dropped; /* children beyond the cap (reported, not silent) */
|
|
} aa_children_t;
|
|
|
|
static aa_tree_child_t *aa_child(aa_children_t *set, const char *name, bool is_file) {
|
|
/* Files and folders can share a name; disambiguate the map key. */
|
|
char key[CBM_SZ_512];
|
|
snprintf(key, sizeof(key), "%c%s", is_file ? 'f' : 'd', name);
|
|
void *slot = cbm_ht_get(set->by_name, key);
|
|
if (slot)
|
|
return &set->items[(int)(intptr_t)slot - 1];
|
|
if (set->count >= AA_TREE_MAX_CHILDREN) {
|
|
set->dropped++;
|
|
return NULL;
|
|
}
|
|
if (set->count >= set->cap) {
|
|
int nc = set->cap ? set->cap * 2 : 64;
|
|
aa_tree_child_t *grown =
|
|
cbm_realloc(CBM_MEM_CLASS_OTHER, set->items, (size_t)nc * sizeof(aa_tree_child_t));
|
|
if (!grown)
|
|
return NULL;
|
|
/* The by_name table stores indices, so growth is safe. */
|
|
set->items = grown;
|
|
set->cap = nc;
|
|
}
|
|
aa_tree_child_t *child = &set->items[set->count];
|
|
memset(child, 0, sizeof(*child));
|
|
child->name = aa_strdup(name);
|
|
child->region = -1;
|
|
child->is_file = is_file;
|
|
if (!child->name)
|
|
return NULL;
|
|
set->count++;
|
|
char *owned_key = aa_strdup(key);
|
|
if (owned_key)
|
|
cbm_ht_set(set->by_name, owned_key, (void *)(intptr_t)set->count);
|
|
return child;
|
|
}
|
|
|
|
static void aa_collect_keys(const char *key, void *value, void *userdata) {
|
|
(void)value;
|
|
/* Keys were heap copies owned by us; collect for freeing. */
|
|
struct {
|
|
char **keys;
|
|
int count;
|
|
int cap;
|
|
} *box = userdata;
|
|
if (box->count >= box->cap)
|
|
return;
|
|
box->keys[box->count++] = (char *)key;
|
|
}
|
|
|
|
static void aa_children_free(aa_children_t *set) {
|
|
struct {
|
|
char **keys;
|
|
int count;
|
|
int cap;
|
|
} box;
|
|
box.cap = set->count * 2 + 8;
|
|
box.keys = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)box.cap * sizeof(char *));
|
|
box.count = 0;
|
|
if (set->by_name && box.keys)
|
|
cbm_ht_foreach(set->by_name, aa_collect_keys, &box);
|
|
for (int i = 0; i < box.count; i++)
|
|
cbm_free(CBM_MEM_CLASS_OTHER, box.keys[i]);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, box.keys);
|
|
cbm_ht_free(set->by_name);
|
|
for (int i = 0; i < set->count; i++)
|
|
cbm_free(CBM_MEM_CLASS_OTHER, set->items[i].name);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, set->items);
|
|
memset(set, 0, sizeof(*set));
|
|
}
|
|
|
|
static int aa_child_cmp(const void *a, const void *b) {
|
|
const aa_tree_child_t *ca = a, *cb = b;
|
|
if (ca->is_file != cb->is_file)
|
|
return ca->is_file - cb->is_file; /* folders first */
|
|
if (cb->symbols != ca->symbols)
|
|
return cb->symbols > ca->symbols ? 1 : -1;
|
|
return strcmp(ca->name, cb->name);
|
|
}
|
|
|
|
char *cbm_atlas_tree_json(cbm_store_t *store, const char *project, const char *path) {
|
|
if (!store || !project)
|
|
return NULL;
|
|
struct sqlite3 *db = cbm_store_get_db(store);
|
|
if (!db)
|
|
return NULL;
|
|
char prefix[CBM_SZ_512];
|
|
aa_norm_path(path, prefix, sizeof(prefix));
|
|
|
|
aa_children_t set;
|
|
memset(&set, 0, sizeof(set));
|
|
set.by_name = cbm_ht_create(256);
|
|
if (!set.by_name)
|
|
return NULL;
|
|
|
|
long long total_symbols = 0;
|
|
int total_files = 0;
|
|
|
|
/* One pass: per-file symbol counts, folded into direct children. */
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT file_path, COUNT(*) FROM nodes WHERE project=?1 AND "
|
|
"file_path IS NOT NULL AND file_path != '' GROUP BY file_path",
|
|
-1, &st, NULL) != SQLITE_OK) {
|
|
aa_children_free(&set);
|
|
return NULL;
|
|
}
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *file = (const char *)sqlite3_column_text(st, 0);
|
|
long long symbols = sqlite3_column_int64(st, 1);
|
|
char comp[CBM_SZ_256];
|
|
bool direct = false;
|
|
if (!file || !aa_child_component(file, prefix, comp, sizeof(comp), &direct))
|
|
continue;
|
|
total_symbols += symbols;
|
|
total_files++;
|
|
aa_tree_child_t *child = aa_child(&set, comp, direct);
|
|
if (!child)
|
|
continue;
|
|
child->symbols += symbols;
|
|
child->files++;
|
|
int region = cbm_layout_region_for_file(store, project, file, NULL);
|
|
if (region >= 0) {
|
|
/* Majority vote, Boyer-Moore style: cheap and order-stable
|
|
* enough for a display hint. */
|
|
if (child->region == region)
|
|
child->region_best++;
|
|
else if (child->region_best == 0) {
|
|
child->region = region;
|
|
child->region_best = 1;
|
|
} else {
|
|
child->region_best--;
|
|
}
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
|
|
/* Missed coverage: File nodes of the shadow project under each child. */
|
|
char shadow[CBM_SZ_512];
|
|
cbm_store_coverage_shadow_project(shadow, sizeof(shadow), project);
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT file_path FROM nodes WHERE project=?1 AND label='File' AND "
|
|
"file_path IS NOT NULL",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, shadow, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *file = (const char *)sqlite3_column_text(st, 0);
|
|
char comp[CBM_SZ_256];
|
|
bool direct = false;
|
|
if (!file || !aa_child_component(file, prefix, comp, sizeof(comp), &direct))
|
|
continue;
|
|
/* Only annotate children that exist in the code graph. */
|
|
char key[CBM_SZ_512];
|
|
snprintf(key, sizeof(key), "%c%s", direct ? 'f' : 'd', comp);
|
|
void *slot = cbm_ht_get(set.by_name, key);
|
|
if (slot)
|
|
set.items[(int)(intptr_t)slot - 1].missed++;
|
|
}
|
|
sqlite3_finalize(st);
|
|
}
|
|
|
|
qsort(set.items, (size_t)set.count, sizeof(aa_tree_child_t), aa_child_cmp);
|
|
|
|
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
|
yyjson_mut_doc_set_root(doc, root);
|
|
yyjson_mut_obj_add_strcpy(doc, root, "path", prefix);
|
|
yyjson_mut_obj_add_int(doc, root, "files", total_files);
|
|
yyjson_mut_obj_add_int(doc, root, "symbols", total_symbols);
|
|
if (set.dropped > 0)
|
|
yyjson_mut_obj_add_int(doc, root, "children_dropped", set.dropped);
|
|
yyjson_mut_val *arr = yyjson_mut_arr(doc);
|
|
for (int i = 0; i < set.count; i++) {
|
|
const aa_tree_child_t *child = &set.items[i];
|
|
yyjson_mut_val *obj = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_strcpy(doc, obj, "name", child->name);
|
|
char full[CBM_SZ_512];
|
|
if (prefix[0])
|
|
snprintf(full, sizeof(full), "%s/%s", prefix, child->name);
|
|
else
|
|
snprintf(full, sizeof(full), "%s", child->name);
|
|
yyjson_mut_obj_add_strcpy(doc, obj, "path", full);
|
|
yyjson_mut_obj_add_str(doc, obj, "kind", child->is_file ? "file" : "dir");
|
|
yyjson_mut_obj_add_int(doc, obj, "symbols", child->symbols);
|
|
yyjson_mut_obj_add_int(doc, obj, "files", child->files);
|
|
if (child->missed > 0)
|
|
yyjson_mut_obj_add_int(doc, obj, "missed", child->missed);
|
|
/* Region id only — the client already holds region names from the
|
|
* regions payload. */
|
|
if (child->region >= 0)
|
|
yyjson_mut_obj_add_int(doc, obj, "region", child->region);
|
|
yyjson_mut_arr_append(arr, obj);
|
|
}
|
|
yyjson_mut_obj_add_val(doc, root, "children", arr);
|
|
char *json = yyjson_mut_write_opts(doc, 0, cbm_json_writer_allocator(), NULL, NULL);
|
|
yyjson_mut_doc_free(doc);
|
|
aa_children_free(&set);
|
|
return json;
|
|
}
|
|
|
|
/* ── /api/symbol ──────────────────────────────────────────────── */
|
|
|
|
/* Call-ish edge families shown on the symbol page, most certain first. */
|
|
#define AA_CALLISH_TYPES "'CALLS','CALL_REFERENCE','USAGE','HTTP_CALLS','ASYNC_CALLS'"
|
|
|
|
static void aa_add_conn_rows(yyjson_mut_doc *doc, yyjson_mut_val *parent, sqlite3_stmt *st,
|
|
cbm_store_t *store, const char *project) {
|
|
yyjson_mut_val *items = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_strcpy(doc, row, "type", (const char *)sqlite3_column_text(st, 0));
|
|
yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 1));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "label", (const char *)sqlite3_column_text(st, 2));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name", (const char *)sqlite3_column_text(st, 3));
|
|
const char *file = (const char *)sqlite3_column_text(st, 4);
|
|
if (file)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", file);
|
|
int line = sqlite3_column_int(st, 5);
|
|
if (line > 0)
|
|
yyjson_mut_obj_add_int(doc, row, "start_line", line);
|
|
const char *qn = (const char *)sqlite3_column_text(st, 6);
|
|
if (qn)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "qualified_name", qn);
|
|
if (file && store) {
|
|
int region = cbm_layout_region_for_file(store, project, file, NULL);
|
|
if (region >= 0)
|
|
yyjson_mut_obj_add_int(doc, row, "region", region);
|
|
}
|
|
yyjson_mut_arr_append(items, row);
|
|
}
|
|
yyjson_mut_obj_add_val(doc, parent, "items", items);
|
|
}
|
|
|
|
/* callers (inbound=true) or callees of `id`, with totals and pagination. */
|
|
static void aa_add_connections(yyjson_mut_doc *doc, yyjson_mut_val *root, struct sqlite3 *db,
|
|
cbm_store_t *store, const char *project, int64_t id, bool inbound,
|
|
int limit, int offset) {
|
|
yyjson_mut_val *section = yyjson_mut_obj(doc);
|
|
const char *count_sql =
|
|
inbound ? "SELECT e.type, COUNT(*) FROM edges e WHERE e.project=?1 AND e.target_id=?2 "
|
|
"AND e.type IN (" AA_CALLISH_TYPES ") GROUP BY e.type"
|
|
: "SELECT e.type, COUNT(*) FROM edges e WHERE e.project=?1 AND e.source_id=?2 "
|
|
"AND e.type IN (" AA_CALLISH_TYPES ") GROUP BY e.type";
|
|
sqlite3_stmt *st = NULL;
|
|
long long total = 0;
|
|
yyjson_mut_val *by_type = yyjson_mut_obj(doc);
|
|
if (sqlite3_prepare_v2(db, count_sql, -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *type = (const char *)sqlite3_column_text(st, 0);
|
|
long long count = sqlite3_column_int64(st, 1);
|
|
/* Copy the key: yyjson borrows add_*() keys, and this one dies
|
|
* with the statement. */
|
|
if (type)
|
|
yyjson_mut_obj_add(by_type, yyjson_mut_strcpy(doc, type),
|
|
yyjson_mut_sint(doc, count));
|
|
total += count;
|
|
}
|
|
sqlite3_finalize(st);
|
|
}
|
|
yyjson_mut_obj_add_int(doc, section, "total", total);
|
|
yyjson_mut_obj_add_val(doc, section, "by_type", by_type);
|
|
yyjson_mut_obj_add_int(doc, section, "limit", limit);
|
|
yyjson_mut_obj_add_int(doc, section, "offset", offset);
|
|
|
|
const char *rows_sql =
|
|
inbound ? "SELECT e.type, n.id, n.label, n.name, n.file_path, n.start_line, "
|
|
"n.qualified_name FROM edges e JOIN nodes n ON n.project=e.project AND "
|
|
"n.id=e.source_id WHERE e.project=?1 AND e.target_id=?2 AND e.type IN "
|
|
"(" AA_CALLISH_TYPES ") ORDER BY CASE e.type WHEN 'CALLS' THEN 0 WHEN "
|
|
"'CALL_REFERENCE' THEN 1 ELSE 2 END, n.id LIMIT ?3 OFFSET ?4"
|
|
: "SELECT e.type, n.id, n.label, n.name, n.file_path, n.start_line, "
|
|
"n.qualified_name FROM edges e JOIN nodes n ON n.project=e.project AND "
|
|
"n.id=e.target_id WHERE e.project=?1 AND e.source_id=?2 AND e.type IN "
|
|
"(" AA_CALLISH_TYPES ") ORDER BY CASE e.type WHEN 'CALLS' THEN 0 WHEN "
|
|
"'CALL_REFERENCE' THEN 1 ELSE 2 END, n.id LIMIT ?3 OFFSET ?4";
|
|
if (sqlite3_prepare_v2(db, rows_sql, -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
sqlite3_bind_int(st, 3, limit);
|
|
sqlite3_bind_int(st, 4, offset);
|
|
aa_add_conn_rows(doc, section, st, store, project);
|
|
sqlite3_finalize(st);
|
|
}
|
|
|
|
/* The shape of what the page cut, not just its size: the hidden
|
|
* remainder grouped by file (top 10 files). A bare "+N more" hides the
|
|
* answer to "who calls this" on every high-degree node. */
|
|
if (total > offset + limit) {
|
|
const char *tail_sql =
|
|
inbound ? "SELECT file_path, COUNT(*) FROM (SELECT n.file_path AS file_path FROM edges "
|
|
"e JOIN nodes n ON n.project=e.project AND n.id=e.source_id WHERE "
|
|
"e.project=?1 AND e.target_id=?2 AND e.type IN (" AA_CALLISH_TYPES ") ORDER "
|
|
"BY CASE e.type WHEN 'CALLS' THEN 0 WHEN 'CALL_REFERENCE' THEN 1 ELSE 2 END, "
|
|
"n.id LIMIT -1 OFFSET ?3) WHERE file_path IS NOT NULL GROUP BY file_path "
|
|
"ORDER BY COUNT(*) DESC, file_path LIMIT 10"
|
|
: "SELECT file_path, COUNT(*) FROM (SELECT n.file_path AS file_path FROM edges "
|
|
"e JOIN nodes n ON n.project=e.project AND n.id=e.target_id WHERE "
|
|
"e.project=?1 AND e.source_id=?2 AND e.type IN (" AA_CALLISH_TYPES ") ORDER "
|
|
"BY CASE e.type WHEN 'CALLS' THEN 0 WHEN 'CALL_REFERENCE' THEN 1 ELSE 2 END, "
|
|
"n.id LIMIT -1 OFFSET ?3) WHERE file_path IS NOT NULL GROUP BY file_path "
|
|
"ORDER BY COUNT(*) DESC, file_path LIMIT 10";
|
|
if (sqlite3_prepare_v2(db, tail_sql, -1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
sqlite3_bind_int(st, 3, offset + limit);
|
|
yyjson_mut_val *tail = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *file = (const char *)sqlite3_column_text(st, 0);
|
|
if (!file)
|
|
continue;
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file", file);
|
|
yyjson_mut_obj_add_int(doc, row, "count", sqlite3_column_int64(st, 1));
|
|
yyjson_mut_arr_append(tail, row);
|
|
}
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, section, "overflow_by_file", tail);
|
|
}
|
|
}
|
|
yyjson_mut_obj_add_val(doc, root, inbound ? "callers" : "callees", section);
|
|
}
|
|
|
|
char *cbm_atlas_symbol_json(cbm_store_t *store, const char *project, int64_t node_id,
|
|
const char *qualified_name, int limit, int offset) {
|
|
if (!store || !project)
|
|
return NULL;
|
|
struct sqlite3 *db = cbm_store_get_db(store);
|
|
if (!db)
|
|
return NULL;
|
|
if (limit <= 0 || limit > 500)
|
|
limit = 50;
|
|
if (offset < 0)
|
|
offset = 0;
|
|
|
|
sqlite3_stmt *st = NULL;
|
|
const char *node_sql =
|
|
node_id >= 0 ? "SELECT id, label, name, qualified_name, file_path, start_line, end_line, "
|
|
"json_extract(properties,'$.docstring'), "
|
|
"json_extract(properties,'$.is_entry_point'), "
|
|
"json_extract(properties,'$.is_test'), "
|
|
"json_extract(properties,'$.is_exported') "
|
|
"FROM nodes WHERE project=?1 AND id=?2"
|
|
: "SELECT id, label, name, qualified_name, file_path, start_line, end_line, "
|
|
"json_extract(properties,'$.docstring'), "
|
|
"json_extract(properties,'$.is_entry_point'), "
|
|
"json_extract(properties,'$.is_test'), "
|
|
"json_extract(properties,'$.is_exported') "
|
|
"FROM nodes WHERE project=?1 AND qualified_name=?2 ORDER BY id LIMIT 1";
|
|
if (sqlite3_prepare_v2(db, node_sql, -1, &st, NULL) != SQLITE_OK)
|
|
return NULL;
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
if (node_id >= 0)
|
|
sqlite3_bind_int64(st, 2, node_id);
|
|
else
|
|
sqlite3_bind_text(st, 2, qualified_name ? qualified_name : "", -1, SQLITE_STATIC);
|
|
if (sqlite3_step(st) != SQLITE_ROW) {
|
|
sqlite3_finalize(st);
|
|
return NULL;
|
|
}
|
|
|
|
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
|
yyjson_mut_doc_set_root(doc, root);
|
|
yyjson_mut_val *node = yyjson_mut_obj(doc);
|
|
int64_t id = sqlite3_column_int64(st, 0);
|
|
yyjson_mut_obj_add_int(doc, node, "id", id);
|
|
yyjson_mut_obj_add_strcpy(doc, node, "label", (const char *)sqlite3_column_text(st, 1));
|
|
yyjson_mut_obj_add_strcpy(doc, node, "name", (const char *)sqlite3_column_text(st, 2));
|
|
const char *qn = (const char *)sqlite3_column_text(st, 3);
|
|
if (qn)
|
|
yyjson_mut_obj_add_strcpy(doc, node, "qualified_name", qn);
|
|
const char *file = (const char *)sqlite3_column_text(st, 4);
|
|
if (file)
|
|
yyjson_mut_obj_add_strcpy(doc, node, "file_path", file);
|
|
int start_line = sqlite3_column_int(st, 5);
|
|
int end_line = sqlite3_column_int(st, 6);
|
|
if (start_line > 0)
|
|
yyjson_mut_obj_add_int(doc, node, "start_line", start_line);
|
|
if (end_line > 0)
|
|
yyjson_mut_obj_add_int(doc, node, "end_line", end_line);
|
|
const char *docstring = (const char *)sqlite3_column_text(st, 7);
|
|
if (docstring && docstring[0])
|
|
yyjson_mut_obj_add_strcpy(doc, node, "docstring", docstring);
|
|
if (sqlite3_column_int(st, 8))
|
|
yyjson_mut_obj_add_bool(doc, node, "is_entry", true);
|
|
if (sqlite3_column_int(st, 9))
|
|
yyjson_mut_obj_add_bool(doc, node, "is_test", true);
|
|
if (sqlite3_column_int(st, 10))
|
|
yyjson_mut_obj_add_bool(doc, node, "is_exported", true);
|
|
char *file_copy = aa_strdup(file);
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, root, "node", node);
|
|
|
|
/* Region membership (cache-backed). */
|
|
if (file_copy) {
|
|
char *region_name = NULL;
|
|
int region = cbm_layout_region_for_file(store, project, file_copy, ®ion_name);
|
|
if (region >= 0) {
|
|
yyjson_mut_val *robj = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, robj, "id", region);
|
|
if (region_name)
|
|
yyjson_mut_obj_add_strcpy(doc, robj, "name", region_name);
|
|
yyjson_mut_obj_add_val(doc, root, "region", robj);
|
|
}
|
|
cbm_free(CBM_MEM_CLASS_OTHER, region_name);
|
|
}
|
|
|
|
aa_add_connections(doc, root, db, store, project, id, true, limit, offset);
|
|
aa_add_connections(doc, root, db, store, project, id, false, limit, offset);
|
|
|
|
/* A panel that can only ever say "none" should not render: tell the
|
|
* client whether this project has DATA_FLOWS edges at all. */
|
|
{
|
|
sqlite3_stmt *pst = NULL;
|
|
bool any = false;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT 1 FROM edges WHERE project=?1 AND type='DATA_FLOWS' "
|
|
"LIMIT 1",
|
|
-1, &pst, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(pst, 1, project, -1, SQLITE_STATIC);
|
|
any = sqlite3_step(pst) == SQLITE_ROW;
|
|
sqlite3_finalize(pst);
|
|
}
|
|
yyjson_mut_obj_add_bool(doc, root, "project_has_data_flows", any);
|
|
}
|
|
|
|
/* Tests exercising this symbol (inbound TESTS edges). */
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT n.id, n.name, n.file_path FROM edges e JOIN nodes n ON "
|
|
"n.project=e.project AND n.id=e.source_id WHERE e.project=?1 AND "
|
|
"e.target_id=?2 AND e.type='TESTS' ORDER BY n.id LIMIT 50",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
yyjson_mut_val *tests = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 0));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name", (const char *)sqlite3_column_text(st, 1));
|
|
const char *tf = (const char *)sqlite3_column_text(st, 2);
|
|
if (tf)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", tf);
|
|
yyjson_mut_arr_append(tests, row);
|
|
}
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, root, "tests", tests);
|
|
}
|
|
|
|
/* Co-change partners of the symbol's FILE (either direction). */
|
|
if (file_copy &&
|
|
sqlite3_prepare_v2(
|
|
db,
|
|
"SELECT o.file_path, json_extract(e.properties,'$.coupling_score') FROM nodes f "
|
|
"JOIN edges e ON e.project=f.project AND e.type='FILE_CHANGES_WITH' AND "
|
|
"(e.source_id=f.id OR e.target_id=f.id) JOIN nodes o ON o.project=f.project AND "
|
|
"o.id=CASE WHEN e.source_id=f.id THEN e.target_id ELSE e.source_id END "
|
|
"WHERE f.project=?1 AND f.label='File' AND f.file_path=?2 ORDER BY 2 DESC "
|
|
"LIMIT 20",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, file_copy, -1, SQLITE_STATIC);
|
|
yyjson_mut_val *cochange = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
const char *other = (const char *)sqlite3_column_text(st, 0);
|
|
if (!other)
|
|
continue;
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", other);
|
|
double score = sqlite3_column_double(st, 1);
|
|
if (score > 0)
|
|
yyjson_mut_obj_add_real(doc, row, "score", score);
|
|
yyjson_mut_arr_append(cochange, row);
|
|
}
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, root, "co_change", cochange);
|
|
}
|
|
|
|
/* Data flows (the "follow the value" family): in and out, with the
|
|
* arg→param detail the pipeline stored on the edge. */
|
|
static const char *const data_sql[2] = {
|
|
"SELECT n.id, n.label, n.name, n.file_path, e.properties FROM edges e JOIN nodes n "
|
|
"ON n.project=e.project AND n.id=e.source_id WHERE e.project=?1 AND e.target_id=?2 "
|
|
"AND e.type='DATA_FLOWS' ORDER BY n.id LIMIT 25",
|
|
"SELECT n.id, n.label, n.name, n.file_path, e.properties FROM edges e JOIN nodes n "
|
|
"ON n.project=e.project AND n.id=e.target_id WHERE e.project=?1 AND e.source_id=?2 "
|
|
"AND e.type='DATA_FLOWS' ORDER BY n.id LIMIT 25",
|
|
};
|
|
static const char *const data_field[2] = {"data_in", "data_out"};
|
|
for (int direction = 0; direction < 2; direction++) {
|
|
if (sqlite3_prepare_v2(db, data_sql[direction], -1, &st, NULL) != SQLITE_OK)
|
|
continue;
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
yyjson_mut_val *arr = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 0));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "label", (const char *)sqlite3_column_text(st, 1));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name", (const char *)sqlite3_column_text(st, 2));
|
|
const char *df = (const char *)sqlite3_column_text(st, 3);
|
|
if (df)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", df);
|
|
const char *props = (const char *)sqlite3_column_text(st, 4);
|
|
if (props && props[0] && strcmp(props, "{}") != 0) {
|
|
yyjson_doc *pdoc = yyjson_read(props, strlen(props), 0);
|
|
if (pdoc) {
|
|
yyjson_mut_val *copy = yyjson_val_mut_copy(doc, yyjson_doc_get_root(pdoc));
|
|
if (copy)
|
|
yyjson_mut_obj_add_val(doc, row, "detail", copy);
|
|
yyjson_doc_free(pdoc);
|
|
}
|
|
}
|
|
yyjson_mut_arr_append(arr, row);
|
|
}
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, root, data_field[direction], arr);
|
|
}
|
|
|
|
/* Recent change history + ownership of the file — the rationale proxy
|
|
* (the literature's #1 hardest question). */
|
|
if (file_copy) {
|
|
char *history = cbm_atlas_file_history_json(store, project, file_copy);
|
|
if (history) {
|
|
yyjson_doc *hdoc = yyjson_read(history, strlen(history), 0);
|
|
if (hdoc) {
|
|
yyjson_mut_val *copy = yyjson_val_mut_copy(doc, yyjson_doc_get_root(hdoc));
|
|
if (copy)
|
|
yyjson_mut_obj_add_val(doc, root, "file_history", copy);
|
|
yyjson_doc_free(hdoc);
|
|
}
|
|
cbm_free(CBM_MEM_CLASS_OTHER, history);
|
|
}
|
|
}
|
|
|
|
/* Near-clones (SIMILAR_TO either direction). */
|
|
if (sqlite3_prepare_v2(
|
|
db,
|
|
"SELECT n.id, n.name, n.file_path, json_extract(e.properties,'$.score') FROM "
|
|
"edges e JOIN nodes n ON n.project=e.project AND n.id=CASE WHEN e.source_id=?2 "
|
|
"THEN e.target_id ELSE e.source_id END WHERE e.project=?1 AND "
|
|
"e.type='SIMILAR_TO' AND (e.source_id=?2 OR e.target_id=?2) ORDER BY n.id "
|
|
"LIMIT 20",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(st, 2, id);
|
|
yyjson_mut_val *similar = yyjson_mut_arr(doc);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 0));
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name", (const char *)sqlite3_column_text(st, 1));
|
|
const char *sf = (const char *)sqlite3_column_text(st, 2);
|
|
if (sf)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", sf);
|
|
double score = sqlite3_column_double(st, 3);
|
|
if (score > 0)
|
|
yyjson_mut_obj_add_real(doc, row, "score", score);
|
|
yyjson_mut_arr_append(similar, row);
|
|
}
|
|
sqlite3_finalize(st);
|
|
yyjson_mut_obj_add_val(doc, root, "similar", similar);
|
|
}
|
|
|
|
cbm_free(CBM_MEM_CLASS_OTHER, file_copy);
|
|
char *json = yyjson_mut_write_opts(doc, 0, cbm_json_writer_allocator(), NULL, NULL);
|
|
yyjson_mut_doc_free(doc);
|
|
return json;
|
|
}
|
|
|
|
/* ── Scent: per-region hit counts for a symbol-name search ────────
|
|
* The search-scent idea: a query should be answerable from the coarsest
|
|
* view, so region blobs and list rows carry match counts before any drill.
|
|
* Case-insensitive substring match on node name, bucketed by region via the
|
|
* cached file->region map. */
|
|
|
|
char *cbm_atlas_scent_json(cbm_store_t *store, const char *project, const char *query) {
|
|
if (!store || !project || !query || strlen(query) < 2)
|
|
return NULL;
|
|
struct sqlite3 *db = cbm_store_get_db(store);
|
|
if (!db)
|
|
return NULL;
|
|
|
|
enum { SCENT_MAX_MATCHES = 50000, SCENT_MAX_REGIONS = 4096 };
|
|
long long *counts = cbm_atlas_calloc_array(SCENT_MAX_REGIONS, sizeof(long long));
|
|
if (!counts)
|
|
return NULL;
|
|
long long total = 0, unmapped = 0;
|
|
bool capped = false;
|
|
|
|
/* Escape LIKE wildcards so a literal "_" in the query stays literal. */
|
|
char pattern[512];
|
|
{
|
|
size_t w = 0;
|
|
pattern[w++] = '%';
|
|
for (const char *c = query; *c && w < sizeof(pattern) - 3; c++) {
|
|
if (*c == '%' || *c == '_' || *c == '\\')
|
|
pattern[w++] = '\\';
|
|
pattern[w++] = *c;
|
|
}
|
|
pattern[w++] = '%';
|
|
pattern[w] = '\0';
|
|
}
|
|
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT file_path FROM nodes WHERE project=?1 AND name LIKE ?2 "
|
|
"ESCAPE '\\' COLLATE NOCASE AND file_path IS NOT NULL "
|
|
"AND label NOT IN ('File','Folder')",
|
|
-1, &st, NULL) != SQLITE_OK) {
|
|
cbm_free(CBM_MEM_CLASS_OTHER, counts);
|
|
return NULL;
|
|
}
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_text(st, 2, pattern, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
|
if (total >= SCENT_MAX_MATCHES) {
|
|
capped = true;
|
|
break;
|
|
}
|
|
const char *fp = (const char *)sqlite3_column_text(st, 0);
|
|
total++;
|
|
int region = fp ? cbm_layout_region_for_file(store, project, fp, NULL) : -1;
|
|
if (region >= 0 && region < SCENT_MAX_REGIONS)
|
|
counts[region]++;
|
|
else
|
|
unmapped++;
|
|
}
|
|
sqlite3_finalize(st);
|
|
|
|
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
|
yyjson_mut_doc_set_root(doc, root);
|
|
yyjson_mut_obj_add_strcpy(doc, root, "query", query);
|
|
yyjson_mut_obj_add_int(doc, root, "total", total);
|
|
yyjson_mut_obj_add_int(doc, root, "unmapped", unmapped);
|
|
yyjson_mut_obj_add_bool(doc, root, "capped", capped);
|
|
yyjson_mut_val *arr = yyjson_mut_arr(doc);
|
|
for (int i = 0; i < SCENT_MAX_REGIONS; i++) {
|
|
if (counts[i] == 0)
|
|
continue;
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "region", i);
|
|
yyjson_mut_obj_add_int(doc, row, "count", counts[i]);
|
|
yyjson_mut_arr_append(arr, row);
|
|
}
|
|
yyjson_mut_obj_add_val(doc, root, "regions", arr);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, counts);
|
|
char *json = yyjson_mut_write_opts(doc, 0, cbm_json_writer_allocator(), NULL, NULL);
|
|
yyjson_mut_doc_free(doc);
|
|
return json;
|
|
}
|
|
|
|
/* ── Bridges: the nodes that stitch regions together ──────────────
|
|
* Ranked by DISTINCT foreign regions a node CALLS INTO (cross-call count as
|
|
* tiebreak) — orchestrators, the seam metric. Inbound direction is
|
|
* deliberately ignored (a logger called from everywhere is a utility hub,
|
|
* already listed under hubs) and test files are excluded (a test harness
|
|
* calls everything). */
|
|
|
|
enum { BR_MAX_TRACK = 8, BR_TOP = 5, BR_EDGE_CAP = 2000000 };
|
|
|
|
typedef struct {
|
|
int64_t id;
|
|
char key[24]; /* the ht borrows key pointers — the node owns its key */
|
|
int regions[BR_MAX_TRACK]; /* distinct foreign regions (first 8) */
|
|
int region_count;
|
|
long long cross_calls;
|
|
} br_node_t;
|
|
|
|
static void br_touch(br_node_t *node, int foreign_region) {
|
|
node->cross_calls++;
|
|
for (int i = 0; i < node->region_count; i++)
|
|
if (node->regions[i] == foreign_region)
|
|
return;
|
|
if (node->region_count < BR_MAX_TRACK)
|
|
node->regions[node->region_count++] = foreign_region;
|
|
}
|
|
|
|
char *cbm_atlas_bridges_json(cbm_store_t *store, const char *project) {
|
|
if (!store || !project)
|
|
return NULL;
|
|
struct sqlite3 *db = cbm_store_get_db(store);
|
|
if (!db)
|
|
return NULL;
|
|
|
|
CBMHashTable *ht = cbm_ht_create(4096); /* id (as %lld string) → br_node_t* */
|
|
if (!ht)
|
|
return NULL;
|
|
br_node_t **all = NULL;
|
|
int all_count = 0, all_cap = 0;
|
|
|
|
sqlite3_stmt *st = NULL;
|
|
long long scanned = 0;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT e.source_id, ns.file_path, e.target_id, nt.file_path FROM "
|
|
"edges e JOIN nodes ns ON ns.project=e.project AND ns.id=e.source_id "
|
|
"JOIN nodes nt ON nt.project=e.project AND nt.id=e.target_id WHERE "
|
|
"e.project=?1 AND e.type='CALLS'",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC);
|
|
while (sqlite3_step(st) == SQLITE_ROW && scanned < BR_EDGE_CAP) {
|
|
scanned++;
|
|
const char *sf = (const char *)sqlite3_column_text(st, 1);
|
|
const char *tf = (const char *)sqlite3_column_text(st, 3);
|
|
if (!sf || !tf)
|
|
continue;
|
|
if (cbm_is_test_file_path(sf))
|
|
continue;
|
|
int sr = cbm_layout_region_for_file(store, project, sf, NULL);
|
|
int tr = cbm_layout_region_for_file(store, project, tf, NULL);
|
|
if (sr < 0 || tr < 0 || sr == tr)
|
|
continue;
|
|
int64_t ids[1] = {sqlite3_column_int64(st, 0)};
|
|
int foreign[1] = {tr};
|
|
for (int k = 0; k < 1; k++) {
|
|
char key[24];
|
|
snprintf(key, sizeof(key), "%lld", (long long)ids[k]);
|
|
br_node_t *node = cbm_ht_get(ht, key);
|
|
if (!node) {
|
|
if (all_count >= all_cap) {
|
|
int nc = all_cap ? all_cap * 2 : 256;
|
|
br_node_t **grown =
|
|
cbm_realloc(CBM_MEM_CLASS_OTHER, all, (size_t)nc * sizeof(br_node_t *));
|
|
if (!grown)
|
|
continue;
|
|
all = grown;
|
|
all_cap = nc;
|
|
}
|
|
node = cbm_atlas_calloc_array(1, sizeof(br_node_t));
|
|
if (!node)
|
|
continue;
|
|
node->id = ids[k];
|
|
snprintf(node->key, sizeof(node->key), "%s", key);
|
|
cbm_ht_set(ht, node->key, node);
|
|
all[all_count++] = node;
|
|
}
|
|
br_touch(node, foreign[k]);
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
}
|
|
|
|
/* Top BR_TOP by (region_count, cross_calls) — selection sort is fine. */
|
|
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
|
yyjson_mut_doc_set_root(doc, root);
|
|
yyjson_mut_obj_add_int(doc, root, "cross_edges_scanned", scanned);
|
|
yyjson_mut_val *arr = yyjson_mut_arr(doc);
|
|
for (int pick = 0; pick < BR_TOP; pick++) {
|
|
int best = -1;
|
|
for (int i = 0; i < all_count; i++) {
|
|
if (!all[i])
|
|
continue;
|
|
if (best < 0 || all[i]->region_count > all[best]->region_count ||
|
|
(all[i]->region_count == all[best]->region_count &&
|
|
all[i]->cross_calls > all[best]->cross_calls))
|
|
best = i;
|
|
}
|
|
if (best < 0 || all[best]->region_count < 2)
|
|
break;
|
|
br_node_t *node = all[best];
|
|
all[best] = NULL;
|
|
sqlite3_stmt *nst = NULL;
|
|
if (sqlite3_prepare_v2(db,
|
|
"SELECT name, file_path, qualified_name FROM nodes WHERE "
|
|
"project=?1 AND id=?2 AND label NOT IN ('File','Folder') LIMIT 1",
|
|
-1, &nst, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(nst, 1, project, -1, SQLITE_STATIC);
|
|
sqlite3_bind_int64(nst, 2, node->id);
|
|
if (sqlite3_step(nst) == SQLITE_ROW) {
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "id", node->id);
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name",
|
|
(const char *)sqlite3_column_text(nst, 0));
|
|
const char *file = (const char *)sqlite3_column_text(nst, 1);
|
|
if (file)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "file_path", file);
|
|
const char *qn = (const char *)sqlite3_column_text(nst, 2);
|
|
if (qn)
|
|
yyjson_mut_obj_add_strcpy(doc, row, "qualified_name", qn);
|
|
yyjson_mut_obj_add_int(doc, row, "regions", node->region_count);
|
|
yyjson_mut_obj_add_int(doc, row, "cross_calls", node->cross_calls);
|
|
yyjson_mut_arr_append(arr, row);
|
|
} else {
|
|
pick--; /* a File node slipped in — skip without burning a slot */
|
|
}
|
|
sqlite3_finalize(nst);
|
|
}
|
|
cbm_free(CBM_MEM_CLASS_OTHER, node);
|
|
}
|
|
yyjson_mut_obj_add_val(doc, root, "bridges", arr);
|
|
for (int i = 0; i < all_count; i++)
|
|
cbm_free(CBM_MEM_CLASS_OTHER, all[i]);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, all);
|
|
cbm_ht_free(ht);
|
|
char *json = yyjson_mut_write_opts(doc, 0, cbm_json_writer_allocator(), NULL, NULL);
|
|
yyjson_mut_doc_free(doc);
|
|
return json;
|
|
}
|
|
|
|
/* ── Blast: bucket a file list by region ──────────────────────────
|
|
* The Changes tab hands the impacted files here; the answer is the
|
|
* graph-native review signal "this change touches N regions". */
|
|
|
|
char *cbm_atlas_blast_json(cbm_store_t *store, const char *project, const char *files_csv) {
|
|
if (!store || !project || !files_csv || !files_csv[0])
|
|
return NULL;
|
|
|
|
enum { BL_MAX_REGIONS = 4096, BL_MAX_FILES = 200 };
|
|
long long *counts = cbm_atlas_calloc_array(BL_MAX_REGIONS, sizeof(long long));
|
|
char **names = cbm_atlas_calloc_array(BL_MAX_REGIONS, sizeof(char *));
|
|
if (!counts || !names) {
|
|
cbm_free(CBM_MEM_CLASS_OTHER, counts);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, names);
|
|
return NULL;
|
|
}
|
|
int files = 0, unmapped = 0;
|
|
bool truncated = false;
|
|
|
|
char *copy = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, files_csv);
|
|
if (!copy) {
|
|
cbm_free(CBM_MEM_CLASS_OTHER, counts);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, names);
|
|
return NULL;
|
|
}
|
|
char *cursor = copy;
|
|
while (cursor && *cursor) {
|
|
char *comma = strchr(cursor, ',');
|
|
if (comma)
|
|
*comma = '\0';
|
|
if (*cursor) {
|
|
if (files >= BL_MAX_FILES) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
files++;
|
|
char *region_name = NULL; /* OTHER copy from the lookup; owned here. */
|
|
int region = cbm_layout_region_for_file(store, project, cursor, ®ion_name);
|
|
if (region >= 0 && region < BL_MAX_REGIONS) {
|
|
counts[region]++;
|
|
if (!names[region] && region_name) {
|
|
names[region] = region_name;
|
|
region_name = NULL;
|
|
}
|
|
} else {
|
|
unmapped++;
|
|
}
|
|
cbm_free(CBM_MEM_CLASS_OTHER, region_name);
|
|
}
|
|
cursor = comma ? comma + 1 : NULL;
|
|
}
|
|
cbm_free(CBM_MEM_CLASS_OTHER, copy);
|
|
|
|
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
|
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
|
yyjson_mut_doc_set_root(doc, root);
|
|
yyjson_mut_obj_add_int(doc, root, "files", files);
|
|
yyjson_mut_obj_add_int(doc, root, "unmapped", unmapped);
|
|
yyjson_mut_obj_add_bool(doc, root, "truncated", truncated);
|
|
yyjson_mut_val *arr = yyjson_mut_arr(doc);
|
|
for (int i = 0; i < BL_MAX_REGIONS; i++) {
|
|
if (counts[i] == 0)
|
|
continue;
|
|
yyjson_mut_val *row = yyjson_mut_obj(doc);
|
|
yyjson_mut_obj_add_int(doc, row, "region", i);
|
|
if (names[i])
|
|
yyjson_mut_obj_add_strcpy(doc, row, "name", names[i]);
|
|
yyjson_mut_obj_add_int(doc, row, "count", counts[i]);
|
|
yyjson_mut_arr_append(arr, row);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, names[i]);
|
|
}
|
|
yyjson_mut_obj_add_val(doc, root, "regions", arr);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, counts);
|
|
cbm_free(CBM_MEM_CLASS_OTHER, names);
|
|
char *json = yyjson_mut_write_opts(doc, 0, cbm_json_writer_allocator(), NULL, NULL);
|
|
yyjson_mut_doc_free(doc);
|
|
return json;
|
|
}
|