forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproject-config.ts
More file actions
281 lines (255 loc) · 10.6 KB
/
Copy pathproject-config.ts
File metadata and controls
281 lines (255 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
/**
* Project-scoped configuration: a committed `codegraph.json` at the project
* root that a team shares through version control.
*
* Today it carries one thing — `extensions`, an opt-in map from a custom file
* extension to one of CodeGraph's supported languages. The built-in
* extension → language table (`EXTENSION_MAP` in `extraction/grammars.ts`) is
* otherwise hardcoded, so a codebase that uses a non-standard extension for a
* supported language (e.g. `.dota_lua` for Lua) sees those files silently
* skipped. This lets the project map them once, in a version-controlled file:
*
* {
* "extensions": {
* ".dota_lua": "lua",
* ".tpl": "php"
* }
* }
*
* User mappings merge on TOP of the built-ins and win on conflict, so a project
* can also re-point a built-in extension (e.g. force `.h` → `cpp`). Absent or
* malformed config is the zero-config default — no overrides, no error. Invalid
* individual entries are warned-and-skipped (never fatal): an unparseable
* project file must not break indexing.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Language } from './types';
import { isLanguageSupported } from './extraction/grammars';
import { logWarn } from './errors';
/** Filename of the project-scoped config, resolved relative to the project root. */
export const PROJECT_CONFIG_FILENAME = 'codegraph.json';
export interface ProjectConfig {
/** Map of custom file extension (`.foo`) to a supported language id. */
extensions?: Record<string, string>;
/**
* Gitignore-style patterns naming gitignored directories whose embedded git
* repositories should be indexed anyway — the explicit opt-in to override
* `.gitignore` for nested-repo discovery (#622, #699). Absent/empty (the
* default) means `.gitignore` is fully respected: gitignored embedded repos
* are never discovered or indexed (#970, #976).
*/
includeIgnored?: string[];
/**
* Gitignore-style patterns for paths to keep OUT of the index — even when
* they are git-TRACKED, which `.gitignore` cannot do (#999). The escape hatch
* for a committed vendor/theme/SDK directory (e.g. a checked-in Metronic theme
* under `static/`) that bloats the graph and slows indexing but isn't really
* your code. Matched against project-root-relative paths, so a directory like
* `"static/"`, a double-star vendor glob, or `"assets/theme"` all work.
* Absent/empty (the default) excludes nothing beyond the built-in defaults
* and your `.gitignore`.
*/
exclude?: string[];
}
/** Parsed, validated view of a project's `codegraph.json`. */
interface ParsedConfig {
extensions: Record<string, Language>;
includeIgnored: string[];
exclude: string[];
}
interface CacheEntry {
mtimeMs: number;
config: ParsedConfig;
}
/**
* Cache keyed by project root. The loader is called once per indexing/scan/sync
* operation (and per watch event), so the mtime guard keeps repeat calls to one
* `stat` while a single `codegraph.json` is in force. Keying by root keeps two
* projects in the same process (the daemon / multi-project MCP server) isolated.
*/
const cache = new Map<string, CacheEntry>();
/** Shared frozen empties so the no-config path allocates nothing. */
const EMPTY_EXTENSIONS: Record<string, Language> = Object.freeze({});
const EMPTY_CONFIG: ParsedConfig = Object.freeze({
extensions: EMPTY_EXTENSIONS,
includeIgnored: Object.freeze([]) as unknown as string[],
exclude: Object.freeze([]) as unknown as string[],
});
/**
* Normalize a user-provided extension key to the `.ext` lowercase form used by
* the built-in map. Returns null for keys that can never match a real file
* extension (so the caller warns and skips):
* - empty / just "."
* - multi-part (".d.ts") — language detection keys off the FINAL extension
* only (`lastIndexOf('.')`), so a multi-dot key would never be consulted.
* - anything containing a path separator.
*/
function normalizeExtKey(raw: string): string | null {
if (typeof raw !== 'string') return null;
let ext = raw.trim().toLowerCase();
if (!ext) return null;
if (!ext.startsWith('.')) ext = '.' + ext;
const body = ext.slice(1);
if (!body) return null;
if (body.includes('.') || body.includes('/') || body.includes('\\')) return null;
return ext;
}
/**
* Read + JSON-parse a `codegraph.json` once and return its validated view.
* Every failure mode degrades to the zero-config default — a missing file, bad
* JSON, or a typo'd value never throws.
*/
function parseConfig(file: string): ParsedConfig {
let raw: string;
try {
raw = fs.readFileSync(file, 'utf-8');
} catch {
return EMPTY_CONFIG;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
logWarn(`Ignoring ${PROJECT_CONFIG_FILENAME}: not valid JSON`, {
file,
error: err instanceof Error ? err.message : String(err),
});
return EMPTY_CONFIG;
}
if (!parsed || typeof parsed !== 'object') return EMPTY_CONFIG;
const extensions = extractExtensions(parsed, file);
const includeIgnored = extractIncludeIgnored(parsed, file);
const exclude = extractExclude(parsed, file);
if (extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0 && exclude.length === 0) {
return EMPTY_CONFIG;
}
return { extensions, includeIgnored, exclude };
}
/**
* Validate the `extensions` map. Every failure mode degrades to "no overrides
* from this entry" — a bad value or a typo'd language never throws.
*/
function extractExtensions(parsed: object, file: string): Record<string, Language> {
const exts = (parsed as ProjectConfig).extensions;
if (!exts || typeof exts !== 'object' || Array.isArray(exts)) return EMPTY_EXTENSIONS;
const out: Record<string, Language> = {};
for (const [rawKey, rawVal] of Object.entries(exts)) {
const key = normalizeExtKey(rawKey);
if (!key) {
logWarn(`Ignoring extension mapping in ${PROJECT_CONFIG_FILENAME}: "${rawKey}" is not a valid file extension`, { file });
continue;
}
if (typeof rawVal !== 'string' || !isLanguageSupported(rawVal as Language)) {
logWarn(`Ignoring extension "${rawKey}" in ${PROJECT_CONFIG_FILENAME}: "${String(rawVal)}" is not a supported language`, { file });
continue;
}
out[key] = rawVal as Language;
}
return Object.keys(out).length > 0 ? out : EMPTY_EXTENSIONS;
}
/**
* Validate the `includeIgnored` patterns: an array of non-empty gitignore-style
* strings. A non-array value or a non-string/blank entry warns-and-skips; never
* throws. Patterns are kept verbatim (trimmed) so they match exactly as a
* `.gitignore` line would.
*/
function extractIncludeIgnored(parsed: object, file: string): string[] {
const raw = (parsed as ProjectConfig).includeIgnored;
if (raw === undefined) return [];
if (!Array.isArray(raw)) {
logWarn(`Ignoring "includeIgnored" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry !== 'string' || !entry.trim()) {
logWarn(`Ignoring an "includeIgnored" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
continue;
}
out.push(entry.trim());
}
return out;
}
/**
* Validate the `exclude` patterns: an array of non-empty gitignore-style
* strings naming paths to keep out of the index even when git-tracked (#999). A
* non-array value or a non-string/blank entry warns-and-skips; never throws.
* Patterns are kept verbatim (trimmed) so they match exactly as a `.gitignore`
* line would, against project-root-relative paths.
*/
function extractExclude(parsed: object, file: string): string[] {
const raw = (parsed as ProjectConfig).exclude;
if (raw === undefined) return [];
if (!Array.isArray(raw)) {
logWarn(`Ignoring "exclude" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry !== 'string' || !entry.trim()) {
logWarn(`Ignoring an "exclude" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
continue;
}
out.push(entry.trim());
}
return out;
}
/**
* Load the parsed `codegraph.json` for a project, mtime-cached. A missing or
* malformed file yields the zero-config default. One `stat` (and at most one
* read/parse) while a single config file is in force, shared across every field.
*/
function loadParsedConfig(rootDir: string): ParsedConfig {
const file = path.join(rootDir, PROJECT_CONFIG_FILENAME);
let mtimeMs: number;
try {
mtimeMs = fs.statSync(file).mtimeMs;
} catch {
// No config file — drop any stale cache entry and return the default.
cache.delete(rootDir);
return EMPTY_CONFIG;
}
const entry = cache.get(rootDir);
if (entry && entry.mtimeMs === mtimeMs) return entry.config;
const config = parseConfig(file);
cache.set(rootDir, { mtimeMs, config });
return config;
}
/**
* Load the validated extension overrides for a project, mtime-cached.
*
* Returns a map of `.ext` → supported language id. The result merges on top of
* the built-in extension map at the point of use (see `detectLanguage` /
* `isSourceFile`), with these user mappings taking precedence. Returns an empty
* map when there is no `codegraph.json` (the zero-config default).
*/
export function loadExtensionOverrides(rootDir: string): Record<string, Language> {
return loadParsedConfig(rootDir).extensions;
}
/**
* Load the validated `includeIgnored` patterns for a project, mtime-cached.
*
* These name gitignored directories whose embedded git repositories should be
* indexed despite `.gitignore` (#622, #699). An empty result — the zero-config
* default — means `.gitignore` is fully respected: gitignored embedded repos
* are never discovered or indexed (#970, #976).
*/
export function loadIncludeIgnoredPatterns(rootDir: string): string[] {
return loadParsedConfig(rootDir).includeIgnored;
}
/**
* Load the validated `exclude` patterns for a project, mtime-cached.
*
* These name paths to keep OUT of the index even when git-tracked — the escape
* hatch for a committed vendor/theme/SDK directory `.gitignore` can't drop
* (#999). An empty result — the zero-config default — excludes nothing beyond
* the built-in defaults and the project's `.gitignore`.
*/
export function loadExcludePatterns(rootDir: string): string[] {
return loadParsedConfig(rootDir).exclude;
}
/** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */
export function clearProjectConfigCache(): void {
cache.clear();
}