chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
Reference in New Issue
Block a user