chore: import upstream snapshot with attribution
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:05 +08:00
commit 41cb1c0170
1830 changed files with 38276124 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
/*
* discover.h — File discovery, language detection, and gitignore matching.
*
* Provides:
* - Language detection from filename/extension (CBM_SZ_64 languages)
* - .m file disambiguation (Objective-C vs Magma vs MATLAB)
* - Gitignore-style pattern parsing and matching
* - Recursive directory walk with hardcoded + gitignore filtering
*
* Depends on: foundation (platform.h for file ops), cbm.h (CBMLanguage enum)
*/
#ifndef CBM_DISCOVER_H
#define CBM_DISCOVER_H
#include <stdbool.h>
#include <stdint.h>
/* Use the existing CBMLanguage enum from extraction layer */
#include "cbm.h"
/* ── Language detection ──────────────────────────────────────────── */
/* Detect language from a filename (basename only, not full path).
* Checks special filenames first (Makefile, CMakeLists.txt, etc.),
* then falls back to extension-based lookup.
* Returns CBM_LANG_COUNT if unknown. */
CBMLanguage cbm_language_for_filename(const char *filename);
/* Detect language from a file extension (including the dot, e.g. ".go").
* Returns CBM_LANG_COUNT if unknown. */
CBMLanguage cbm_language_for_extension(const char *ext);
/* Get the human-readable name for a language enum value.
* Returns "Unknown" for CBM_LANG_COUNT or out-of-range values. */
const char *cbm_language_name(CBMLanguage lang);
/* Disambiguate .m files by reading first 4KB of content.
* Returns CBM_LANG_OBJC, CBM_LANG_MAGMA, or CBM_LANG_MATLAB.
* On read failure, defaults to CBM_LANG_MATLAB. */
CBMLanguage cbm_disambiguate_m(const char *path);
/* ── Gitignore pattern matching ──────────────────────────────────── */
typedef struct cbm_gitignore cbm_gitignore_t;
/* Parse gitignore patterns from a file. Returns NULL on error (file not found, etc.).
* Caller must call cbm_gitignore_free(). */
cbm_gitignore_t *cbm_gitignore_load(const char *path);
/* Parse gitignore patterns from a string (for testing).
* Caller must call cbm_gitignore_free(). */
cbm_gitignore_t *cbm_gitignore_parse(const char *content);
/* Check if a relative path matches any gitignore pattern.
* rel_path should use '/' separators. is_dir indicates if path is a directory. */
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir);
/* Free a gitignore matcher. NULL-safe. */
void cbm_gitignore_free(cbm_gitignore_t *gi);
/* Append all patterns from src into dst. dst takes ownership of deep copies
* of each src pattern; src is unchanged and must still be freed by the caller.
* NULL-safe on either argument.
* Returns true on success (or when there is nothing to merge). Returns false on
* allocation failure, in which case dst is left exactly as it was (atomic) — no
* partial merge — so a failed merge degrades to "as if src was absent". */
bool cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src);
/* ── Directory skip / suffix filters ─────────────────────────────── */
/* Index mode controls filtering aggressiveness.
* IMPORTANT: these values MUST match pipeline.h exactly. A previous
* mismatch (this header had FAST=1, pipeline.h has FAST=2) caused
* fast-mode filtering to silently no-op depending on include order —
* the pipeline passed value 2, discover.c compared against 1, and no
* files got filtered. */
#ifndef CBM_INDEX_MODE_T_DEFINED
#define CBM_INDEX_MODE_T_DEFINED
typedef enum {
CBM_MODE_FULL = 0, /* parse everything supported */
CBM_MODE_MODERATE = 1, /* aggressive filtering + similarity/semantic edges */
CBM_MODE_FAST = 2, /* aggressive filtering + no similarity/semantic edges */
} cbm_index_mode_t;
#endif
/* Check if a directory name should always be skipped (e.g. .git, node_modules).
* Only checks the basename, not the full path. */
bool cbm_should_skip_dir(const char *dirname, cbm_index_mode_t mode);
/* Check if a file has a suffix that should be skipped (e.g. .pyc, .png). */
bool cbm_has_ignored_suffix(const char *filename, cbm_index_mode_t mode);
/* Check if a specific filename should be skipped in fast mode (e.g. LICENSE, go.sum). */
bool cbm_should_skip_filename(const char *filename, cbm_index_mode_t mode);
/* Check if a path matches fast-mode substring patterns (e.g. .d.ts, .pb.go). */
bool cbm_matches_fast_pattern(const char *filename, cbm_index_mode_t mode);
/* ── File discovery ──────────────────────────────────────────────── */
typedef struct {
char *path; /* absolute path (heap-allocated) */
char *rel_path; /* relative to repo root (heap-allocated) */
CBMLanguage language; /* detected language */
int64_t size; /* file size in bytes */
} cbm_file_info_t;
typedef struct {
cbm_index_mode_t mode; /* CBM_MODE_FULL or CBM_MODE_FAST */
const char *ignore_file; /* path to .cbmignore file, or NULL */
int64_t max_file_size; /* 0 = no limit */
} cbm_discover_opts_t;
/* Walk a repository directory tree and discover all source files.
* Applies hardcoded filters, gitignore patterns, and language detection.
* Returns 0 on success, -1 on error.
* Caller must call cbm_discover_free() on the results. */
int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count);
/* Like cbm_discover(), but also reports the directory subtrees that were
* skipped during the walk (hardcoded ALWAYS_SKIP/FAST_SKIP dirs + gitignore
* matches), so callers can surface which subtrees were dropped (#411).
* On success, *excluded_out receives a heap-allocated array of strdup'd
* relative directory paths and *excluded_count_out its length; the caller
* owns it and must free via cbm_discover_free_excluded(). Pass NULL for
* excluded_out (and/or excluded_count_out) to discard the list — the internal
* accumulator is freed in that case (no leak).
* Returns 0 on success, -1 on error. */
int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out);
/* One deliberately-not-indexed file (#963): an individual file dropped by an
* ignore mechanism during the walk (its parent directory was NOT excluded —
* whole subtrees are reported separately as excluded dirs). BY DESIGN, not a
* failure. */
typedef struct {
char *rel_path; /* heap-allocated, relative to repo root */
char *reason; /* heap-allocated: "gitignore" | "cbmignore" |
* "skip-list" | "ignored-suffix" | "fast-pattern" |
* "size-cap" */
} cbm_ignored_file_t;
/* Stored per-file ignore entries are capped (the walk still counts ALL of
* them in *ignored_total_out, so truncation is always explicit, never
* silent). Whole excluded subtrees stay exhaustive via excluded_out. */
enum { CBM_DISCOVER_IGNORED_CAP = 2000 };
/* Like cbm_discover_ex(), but additionally reports the individual files that
* ignore rules dropped (#963 "purposely not indexed"). *ignored_out receives
* a heap array (caller frees via cbm_discover_free_ignored),
* *ignored_count_out its stored length (<= CBM_DISCOVER_IGNORED_CAP), and
* *ignored_total_out the TOTAL number of ignored files seen. Pass NULL to
* skip the collection entirely. */
int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out,
cbm_ignored_file_t **ignored_out, int *ignored_count_out,
int *ignored_total_out);
/* Free an array of file info results. NULL-safe. */
void cbm_discover_free(cbm_file_info_t *files, int count);
/* Free the excluded-directory list returned by cbm_discover_ex(). NULL-safe. */
void cbm_discover_free_excluded(char **excluded, int count);
/* Free the ignored-file list returned by cbm_discover_ex2(). NULL-safe. */
void cbm_discover_free_ignored(cbm_ignored_file_t *ignored, int count);
#endif /* CBM_DISCOVER_H */
+430
View File
@@ -0,0 +1,430 @@
/*
* gitignore.c — Gitignore-style pattern matching.
*
* Implements the core gitignore pattern matching algorithm:
* - * matches anything except /
* - ** matches any number of path components
* - ? matches any single character except /
* - [abc] and [a-z] character classes
* - ! prefix for negation
* - trailing / for directory-only matching
* - patterns with / are rooted (anchored to base)
*/
#include "foundation/constants.h"
#include "foundation/compat_fs.h"
enum { GI_INIT_CAP = 16, GI_CHAR_IDX1 = 1, GI_CHAR_IDX2 = 2, GI_SKIP3 = 3 };
#include "discover/discover.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Pattern representation ──────────────────────────────────────── */
typedef struct {
char *pattern; /* the glob pattern (normalized) */
bool negated; /* starts with ! */
bool dir_only; /* ends with / */
bool rooted; /* contains / (anchored to root) */
} gi_pattern_t;
struct cbm_gitignore {
gi_pattern_t *patterns;
int count;
int capacity;
};
/* ── Pattern matching engine ─────────────────────────────────────── */
/* Forward declaration for recursive calls. */
static bool glob_match(const char *pat, const char *str); // NOLINT(misc-no-recursion)
/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */
static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
if (glob_match(pat, str)) {
return true;
}
for (const char *s = str; *s; s++) {
if (*s == '/' && glob_match(pat, s + SKIP_ONE)) {
return true;
}
}
return false;
}
/* Match a ** (doublestar) followed by non-slash: try at every position. */
static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
return true;
}
if (!*s) {
return false;
}
}
}
/* Match a * (single star): match any sequence not containing /. */
static bool glob_match_star(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
return true;
}
if (!*s || *s == '/') {
return false;
}
}
}
/* Match a [...] character class at current position.
* Returns true if matched. Advances *pat_out past the closing ']'. */
static bool glob_match_charclass(const char *pat, char ch, const char **pat_out) {
bool negate_class = false;
if (*pat == '!' || *pat == '^') {
negate_class = true;
pat++;
}
bool matched = false;
char prev = 0;
while (*pat && *pat != ']') {
if (*pat == '-' && prev && pat[GI_CHAR_IDX1] && pat[GI_CHAR_IDX1] != ']') {
pat++;
if (ch >= prev && ch <= *pat) {
matched = true;
}
prev = *pat;
pat++;
} else {
if (ch == *pat) {
matched = true;
}
prev = *pat;
pat++;
}
}
if (*pat == ']') {
pat++;
}
*pat_out = pat;
return negate_class ? !matched : matched;
}
/*
* Match a glob pattern against a string.
* Handles: * (non-slash), ** (any path), ? (single non-slash), [class]
*/
/* Handle ** at current position. Returns match result. */
static bool glob_match_doublestar(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
if (pat[GI_CHAR_IDX2] == '/') {
return glob_match_doublestar_slash(pat + GI_SKIP3, str);
}
if (pat[GI_CHAR_IDX2] == '\0') {
return true;
}
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str);
}
static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
while (*pat && *str) {
if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') {
return glob_match_doublestar(pat, str);
}
if (*pat == '*') {
return glob_match_star(pat + SKIP_ONE, str);
}
if (*pat == '?') {
if (*str == '/') {
return false;
}
pat++;
str++;
continue;
}
if (*pat == '[') {
const char *new_pat = NULL;
if (!glob_match_charclass(pat + SKIP_ONE, *str, &new_pat)) {
return false;
}
pat = new_pat;
str++;
continue;
}
if (*pat != *str) {
return false;
}
pat++;
str++;
}
while (*pat == '*') {
pat++;
}
return *pat == '\0' && *str == '\0';
}
/* ── Pattern parsing ─────────────────────────────────────────────── */
static void gi_add_pattern(cbm_gitignore_t *gi, const char *line, int len) {
/* Trim trailing whitespace */
while (len > 0 && (line[len - SKIP_ONE] == ' ' || line[len - SKIP_ONE] == '\t' ||
line[len - SKIP_ONE] == '\r')) {
len--;
}
if (len == 0) {
return;
}
gi_pattern_t p = {0};
/* Check for negation */
const char *start = line;
if (*start == '!') {
p.negated = true;
start++;
len--;
}
if (len == 0) {
return;
}
/* Check for trailing / (directory-only) */
if (start[len - SKIP_ONE] == '/') {
p.dir_only = true;
len--;
}
if (len == 0) {
return;
}
/* Check for leading / (rooted) */
if (*start == '/') {
p.rooted = true;
start++;
len--;
}
if (len == 0) {
return;
}
/* Check if pattern contains / anywhere (makes it rooted) */
if (!p.rooted) {
for (int i = 0; i < len; i++) {
if (start[i] == '/') {
p.rooted = true;
break;
}
}
}
/* Copy pattern */
p.pattern = malloc(len + SKIP_ONE);
if (!p.pattern) {
return;
}
memcpy(p.pattern, start, len);
p.pattern[len] = '\0';
/* Grow array if needed */
if (gi->count >= gi->capacity) {
int new_cap = gi->capacity ? gi->capacity * PAIR_LEN : GI_INIT_CAP;
gi_pattern_t *new_patterns = realloc(gi->patterns, new_cap * sizeof(gi_pattern_t));
if (!new_patterns) {
free(p.pattern);
return;
}
gi->patterns = new_patterns;
gi->capacity = new_cap;
}
gi->patterns[gi->count++] = p;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_gitignore_t *cbm_gitignore_parse(const char *content) {
if (!content) {
return NULL;
}
cbm_gitignore_t *gi = calloc(CBM_ALLOC_ONE, sizeof(cbm_gitignore_t));
if (!gi) {
return NULL;
}
const char *line = content;
while (*line) {
/* Find end of line */
const char *eol = strchr(line, '\n');
int len = eol ? (int)(eol - line) : (int)strlen(line);
/* Skip comments and blank lines */
if (len > 0 && line[0] != '#') {
gi_add_pattern(gi, line, len);
}
if (!eol) {
break;
}
line = eol + SKIP_ONE;
}
return gi;
}
cbm_gitignore_t *cbm_gitignore_load(const char *path) {
if (!path) {
return NULL;
}
FILE *f = cbm_fopen(path, "r");
if (!f) {
return NULL;
}
/* Read entire file */
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0) {
(void)fclose(f);
return cbm_gitignore_parse("");
}
char *buf = malloc(size + SKIP_ONE);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t n = fread(buf, SKIP_ONE, size, f);
buf[n] = '\0';
(void)fclose(f);
cbm_gitignore_t *gi = cbm_gitignore_parse(buf);
free(buf);
return gi;
}
/* Match a non-rooted pattern against basename and path suffixes. */
static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) {
if (glob_match(pattern, basename)) {
return true;
}
if (!strchr(rel_path, '/')) {
return false;
}
/* Try matching at every / boundary */
const char *s = rel_path;
while (*s) {
if (glob_match(pattern, s)) {
return true;
}
const char *next = strchr(s, '/');
if (!next) {
break;
}
s = next + SKIP_ONE;
}
return false;
}
int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
if (!gi || !rel_path) {
return 0;
}
/* Extract the basename for non-rooted pattern matching */
const char *basename = strrchr(rel_path, '/');
basename = basename ? basename + SKIP_ONE : rel_path;
int matched = 0;
for (int i = 0; i < gi->count; i++) {
const gi_pattern_t *p = &gi->patterns[i];
if (p->dir_only && !is_dir) {
continue;
}
bool this_match = p->rooted ? glob_match(p->pattern, rel_path)
: match_unrooted(p->pattern, rel_path, basename);
if (this_match) {
matched = p->negated ? -1 : 1;
}
}
return matched;
}
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
return cbm_gitignore_match_result(gi, rel_path, is_dir) > 0;
}
void cbm_gitignore_free(cbm_gitignore_t *gi) {
if (!gi) {
return;
}
for (int i = 0; i < gi->count; i++) {
free(gi->patterns[i].pattern);
}
free(gi->patterns);
free(gi);
}
/* Test seam: lets a unit test simulate strdup() failure mid-merge so the
* atomic-rollback path can be exercised without real OOM. NULL = use strdup. */
char *(*cbm_gitignore_merge_dup_hook_for_test)(const char *) = NULL;
bool cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src) {
if (!dst) {
return false;
}
if (!src || src->count == 0) {
return true; /* nothing to merge */
}
int needed = dst->count + src->count;
if (needed > dst->capacity) {
gi_pattern_t *grown = realloc(dst->patterns, (size_t)needed * sizeof(gi_pattern_t));
if (!grown) {
return false; /* dst left unchanged */
}
dst->patterns = grown;
dst->capacity = needed;
}
int start_count = dst->count;
for (int i = 0; i < src->count; i++) {
char *pat = cbm_gitignore_merge_dup_hook_for_test
? cbm_gitignore_merge_dup_hook_for_test(src->patterns[i].pattern)
: strdup(src->patterns[i].pattern);
if (!pat) {
/* Roll back partial copies so dst is unchanged on failure (atomic
* merge). A silent partial merge could drop the very exclude
* pattern the caller relied on while keeping others. */
for (int j = start_count; j < dst->count; j++) {
free(dst->patterns[j].pattern);
}
dst->count = start_count;
return false;
}
dst->patterns[dst->count].pattern = pat;
dst->patterns[dst->count].negated = src->patterns[i].negated;
dst->patterns[dst->count].dir_only = src->patterns[i].dir_only;
dst->patterns[dst->count].rooted = src->patterns[i].rooted;
dst->count++;
}
return true;
}
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
/*
* userconfig.c — User-defined extension→language mappings.
*
* Reads extra_extensions from:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*/
#include "discover/userconfig.h"
#include "cbm.h" /* CBMLanguage, CBM_LANG_* */
#include "foundation/constants.h"
#include "foundation/platform.h" /* cbm_safe_getenv */
#include "foundation/compat_fs.h"
enum { MAX_CONFIG_SIZE = 65536 };
#include "foundation/log.h"
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Process-global user config pointer ──────────────────────────── */
static const cbm_userconfig_t *g_userconfig = NULL;
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg) {
g_userconfig = cfg;
}
const cbm_userconfig_t *cbm_get_user_lang_config(void) {
return g_userconfig;
}
/* ── Language name → enum table ──────────────────────────────────── */
/*
* Reverse-mapping from lowercase language name strings to CBMLanguage.
* Covers all names exposed by cbm_language_name() plus common aliases.
*/
typedef struct {
const char *name; /* lowercase */
CBMLanguage lang;
} lang_name_entry_t;
static const lang_name_entry_t LANG_NAME_TABLE[] = {
{"go", CBM_LANG_GO},
{"python", CBM_LANG_PYTHON},
{"javascript", CBM_LANG_JAVASCRIPT},
{"typescript", CBM_LANG_TYPESCRIPT},
{"tsx", CBM_LANG_TSX},
{"rust", CBM_LANG_RUST},
{"java", CBM_LANG_JAVA},
{"c++", CBM_LANG_CPP},
{"cpp", CBM_LANG_CPP},
{"c#", CBM_LANG_CSHARP},
{"csharp", CBM_LANG_CSHARP},
{"php", CBM_LANG_PHP},
{"lua", CBM_LANG_LUA},
{"scala", CBM_LANG_SCALA},
{"kotlin", CBM_LANG_KOTLIN},
{"ruby", CBM_LANG_RUBY},
{"c", CBM_LANG_C},
{"bash", CBM_LANG_BASH},
{"sh", CBM_LANG_BASH},
{"zig", CBM_LANG_ZIG},
{"elixir", CBM_LANG_ELIXIR},
{"haskell", CBM_LANG_HASKELL},
{"ocaml", CBM_LANG_OCAML},
{"objective-c", CBM_LANG_OBJC},
{"objc", CBM_LANG_OBJC},
{"swift", CBM_LANG_SWIFT},
{"dart", CBM_LANG_DART},
{"perl", CBM_LANG_PERL},
{"groovy", CBM_LANG_GROOVY},
{"erlang", CBM_LANG_ERLANG},
{"r", CBM_LANG_R},
{"html", CBM_LANG_HTML},
{"css", CBM_LANG_CSS},
{"scss", CBM_LANG_SCSS},
{"yaml", CBM_LANG_YAML},
{"toml", CBM_LANG_TOML},
{"hcl", CBM_LANG_HCL},
{"terraform", CBM_LANG_HCL},
{"sql", CBM_LANG_SQL},
{"dockerfile", CBM_LANG_DOCKERFILE},
{"clojure", CBM_LANG_CLOJURE},
{"f#", CBM_LANG_FSHARP},
{"fsharp", CBM_LANG_FSHARP},
{"julia", CBM_LANG_JULIA},
{"vimscript", CBM_LANG_VIMSCRIPT},
{"nix", CBM_LANG_NIX},
{"common lisp", CBM_LANG_COMMONLISP},
{"commonlisp", CBM_LANG_COMMONLISP},
{"lisp", CBM_LANG_COMMONLISP},
{"elm", CBM_LANG_ELM},
{"fortran", CBM_LANG_FORTRAN},
{"cuda", CBM_LANG_CUDA},
{"cobol", CBM_LANG_COBOL},
{"verilog", CBM_LANG_VERILOG},
{"emacs lisp", CBM_LANG_EMACSLISP},
{"emacslisp", CBM_LANG_EMACSLISP},
{"json", CBM_LANG_JSON},
{"xml", CBM_LANG_XML},
{"markdown", CBM_LANG_MARKDOWN},
{"makefile", CBM_LANG_MAKEFILE},
{"cmake", CBM_LANG_CMAKE},
{"protobuf", CBM_LANG_PROTOBUF},
{"graphql", CBM_LANG_GRAPHQL},
{"vue", CBM_LANG_VUE},
{"svelte", CBM_LANG_SVELTE},
{"meson", CBM_LANG_MESON},
{"glsl", CBM_LANG_GLSL},
{"ini", CBM_LANG_INI},
{"matlab", CBM_LANG_MATLAB},
{"mojo", CBM_LANG_MOJO},
{"lean", CBM_LANG_LEAN},
{"form", CBM_LANG_FORM},
{"magma", CBM_LANG_MAGMA},
{"wolfram", CBM_LANG_WOLFRAM},
};
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
/*
* Parse a language string (case-insensitive) to a CBMLanguage enum.
* Returns CBM_LANG_COUNT if the string is not recognized.
*/
static CBMLanguage lang_from_string(const char *s) {
if (!s || !s[0]) {
return CBM_LANG_COUNT;
}
/* Build a lowercase copy for comparison */
char lower[CBM_SZ_64];
size_t i;
for (i = 0; i < sizeof(lower) - SKIP_ONE && s[i]; i++) {
lower[i] = (char)tolower((unsigned char)s[i]);
}
lower[i] = '\0';
for (size_t j = 0; j < LANG_NAME_TABLE_SIZE; j++) {
if (strcmp(LANG_NAME_TABLE[j].name, lower) == 0) {
return LANG_NAME_TABLE[j].lang;
}
}
return CBM_LANG_COUNT;
}
/* ── Config directory helper ─────────────────────────────────────── */
/* cbm_app_config_dir() is now in platform.c (cross-platform). */
/* ── JSON parsing ────────────────────────────────────────────────── */
/*
* Parse extra_extensions from a yyjson object root.
* Appends valid entries to *entries / *count (growing via realloc).
* Project-level entries (from_project=true) are appended after global
* entries so that a later dedup pass can prefer project values.
*
* Returns 0 on success, -1 on alloc failure.
*/
static int parse_extra_extensions(yyjson_val *root, cbm_userext_t **entries, int *count,
const char *source_label) {
if (!yyjson_is_obj(root)) {
cbm_log_warn("userconfig.bad_root", "file", source_label);
return 0;
}
yyjson_val *extra = yyjson_obj_get(root, "extra_extensions");
if (!extra) {
return 0; /* key absent — fine */
}
if (!yyjson_is_obj(extra)) {
cbm_log_warn("userconfig.bad_extra_extensions", "file", source_label);
return 0;
}
yyjson_obj_iter iter;
yyjson_obj_iter_init(extra, &iter);
yyjson_val *key;
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
yyjson_val *val = yyjson_obj_iter_get_val(key);
const char *ext_str = yyjson_get_str(key);
const char *lang_str = yyjson_get_str(val);
if (!ext_str || !lang_str) {
cbm_log_warn("userconfig.skip_non_string", "file", source_label);
continue;
}
/* Extension must start with '.' */
if (ext_str[0] != '.') {
cbm_log_warn("userconfig.skip_bad_ext", "file", source_label, "ext", ext_str);
continue;
}
CBMLanguage lang = lang_from_string(lang_str);
if (lang == CBM_LANG_COUNT) {
cbm_log_warn("userconfig.unknown_lang", "file", source_label, "lang", lang_str);
continue; /* fail-open: skip unknown languages */
}
/* Grow the array */
cbm_userext_t *tmp = realloc(*entries, (size_t)(*count + SKIP_ONE) * sizeof(cbm_userext_t));
if (!tmp) {
return CBM_NOT_FOUND;
}
*entries = tmp;
char *ext_copy = strdup(ext_str);
if (!ext_copy) {
return CBM_NOT_FOUND;
}
(*entries)[*count].ext = ext_copy;
(*entries)[*count].lang = lang;
(*count)++;
}
return 0;
}
/*
* Read a JSON file and parse extra_extensions from it.
* Silently ignores missing files. Logs warnings for corrupt JSON.
* Returns 0 on success (or absent file), -1 on alloc failure.
*/
static int load_config_file(const char *path, cbm_userext_t **entries, int *count) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return 0; /* file absent — silently ignore */
}
if (fseek(f, 0, SEEK_END) != 0) {
(void)fclose(f);
return 0;
}
long len = ftell(f);
if (fseek(f, 0, SEEK_SET) != 0) {
(void)fclose(f);
return 0;
}
if (len <= 0 || len > MAX_CONFIG_SIZE) {
(void)fclose(f);
if (len > MAX_CONFIG_SIZE) {
cbm_log_warn("userconfig.file_too_large", "path", path);
}
return 0;
}
char *buf = malloc((size_t)len + SKIP_ONE);
if (!buf) {
(void)fclose(f);
return CBM_NOT_FOUND;
}
size_t nread = fread(buf, SKIP_ONE, (size_t)len, f);
(void)fclose(f);
if (nread > (size_t)len) {
nread = (size_t)len;
}
buf[nread] = '\0';
yyjson_doc *doc = yyjson_read(buf, nread, 0);
free(buf);
if (!doc) {
cbm_log_warn("userconfig.corrupt_json", "path", path);
return 0; /* corrupt JSON — silently ignore (fail-open) */
}
yyjson_val *root = yyjson_doc_get_root(doc);
int rc = parse_extra_extensions(root, entries, count, path);
yyjson_doc_free(doc);
return rc;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) {
cbm_userconfig_t *cfg = calloc(CBM_ALLOC_ONE, sizeof(cbm_userconfig_t));
if (!cfg) {
return NULL;
}
cbm_userext_t *entries = NULL;
int count = 0;
/* ── Step 1: Load global config ── */
enum { PATH_BUF_SZ = 1280 };
const char *cfg_base = cbm_app_config_dir();
const char *cfg_fallback = cfg_base ? cfg_base : "/tmp";
char global_path[PATH_BUF_SZ];
snprintf(global_path, sizeof(global_path), "%s/codebase-memory-mcp/config.json", cfg_fallback);
if (load_config_file(global_path, &entries, &count) != 0) {
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
int global_count = count; /* entries[0..global_count) are from global */
/* ── Step 2: Load project config ── */
if (repo_path && repo_path[0]) {
char project_path[PATH_BUF_SZ];
snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path);
if (load_config_file(project_path, &entries, &count) != 0) {
/* Free already-allocated entries */
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
}
/*
* ── Step 3: Dedup — project entries win over global ──
*
* For any extension that appears in both global (indices 0..global_count)
* and project (indices global_count..count), remove the global entry by
* replacing it with the last global entry (order-insensitive dedup).
*/
for (int p = global_count; p < count; p++) {
for (int g = 0; g < global_count; g++) {
if (entries[g].ext && strcmp(entries[g].ext, entries[p].ext) == 0) {
/* Remove global entry: overwrite with last global entry */
free(entries[g].ext);
entries[g] = entries[global_count - SKIP_ONE];
entries[global_count - SKIP_ONE].ext = NULL; /* mark as consumed */
global_count--;
break;
}
}
}
/*
* Compact: remove any NULL-ext slots left by the dedup step.
* (Those are the consumed "last global" entries.)
*/
int write_idx = 0;
for (int i = 0; i < count; i++) {
if (entries[i].ext != NULL) {
entries[write_idx++] = entries[i];
}
}
count = write_idx;
cfg->entries = entries;
cfg->count = count;
return cfg;
}
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) {
if (!cfg || !ext || !ext[0]) {
return CBM_LANG_COUNT;
}
for (int i = 0; i < cfg->count; i++) {
if (cfg->entries[i].ext && strcmp(cfg->entries[i].ext, ext) == 0) {
return cfg->entries[i].lang;
}
}
return CBM_LANG_COUNT;
}
void cbm_userconfig_free(cbm_userconfig_t *cfg) {
if (!cfg) {
return;
}
for (int i = 0; i < cfg->count; i++) {
free(cfg->entries[i].ext);
}
free(cfg->entries);
free(cfg);
}
+72
View File
@@ -0,0 +1,72 @@
/*
* userconfig.h — User-defined file extension → language mappings.
*
* Reads extra_extensions from two optional JSON config files:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*
* Format:
* {"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
*
* The language string matching is case-insensitive.
*/
#ifndef CBM_USERCONFIG_H
#define CBM_USERCONFIG_H
#include "cbm.h" /* CBMLanguage */
/* ── Types ──────────────────────────────────────────────────────── */
typedef struct {
char *ext; /* file extension including dot, e.g. ".blade.php" */
CBMLanguage lang; /* resolved language enum */
} cbm_userext_t;
typedef struct {
cbm_userext_t *entries; /* heap-allocated array */
int count; /* number of entries */
} cbm_userconfig_t;
/* ── API ────────────────────────────────────────────────────────── */
/*
* Load user config from global + project files, merge (project wins).
* repo_path: absolute path to the repository root (for project config).
* Returns a heap-allocated cbm_userconfig_t (caller must free via
* cbm_userconfig_free). Returns NULL only on allocation failure.
* Missing config files are silently ignored.
*/
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path);
/*
* Look up a file extension in the user config.
* ext: extension including dot, e.g. ".blade.php"
* Returns the mapped CBMLanguage, or CBM_LANG_COUNT if not found.
*/
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext);
/* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */
void cbm_userconfig_free(cbm_userconfig_t *cfg);
/* ── Integration hook ───────────────────────────────────────────── */
/*
* Set the process-global user config that cbm_language_for_extension()
* will consult before the built-in table.
* cfg may be NULL to clear the override.
* Not thread-safe — call before spawning worker threads.
*/
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg);
/*
* Get the currently active process-global user config.
* Returns NULL if none has been set.
* Called internally by cbm_language_for_extension().
*/
const cbm_userconfig_t *cbm_get_user_lang_config(void);
#endif /* CBM_USERCONFIG_H */