|
| 1 | +/** |
| 2 | + * A class, struct, union or enum DEFINED in the type of a C or C++ variable |
| 3 | + * declaration was never indexed: |
| 4 | + * |
| 5 | + * namespace n { struct Foo { int a; void f() {} } foo; } // only n::foo |
| 6 | + * static struct { int argc; char **argv; } SPT; // nothing at all |
| 7 | + * enum { MODE_IDLE, MODE_BUSY } current_mode; // no enumerators |
| 8 | + * |
| 9 | + * Both extractors handed such a declaration to the variable extractor and |
| 10 | + * skipped its children, so the type, its methods and its enumerators never |
| 11 | + * became nodes, a base class defined this way had nothing to resolve to, and |
| 12 | + * calls on its methods linked to nothing. |
| 13 | + * |
| 14 | + * The type is now walked like one written on its own, beside the variables |
| 15 | + * (which keep their nodes), and the comment above the declaration documents |
| 16 | + * it. An unnamed one takes the name of the first variable its declaration |
| 17 | + * declares, as `typedef struct { … } Name;` takes the typedef name: no code |
| 18 | + * can name the type of `SPT`, only `SPT`. In a C++ file whose tree has errors |
| 19 | + * the type is walked in the scopes the file's braces open (#2426), and not at |
| 20 | + * all when the braces don't balance. Runs against the required native kernel. |
| 21 | + */ |
| 22 | +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; |
| 23 | +import * as fs from 'fs'; |
| 24 | +import * as os from 'os'; |
| 25 | +import * as path from 'path'; |
| 26 | +import CodeGraph from '../src/index'; |
| 27 | +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; |
| 28 | +import type { ExtractionResult, Language } from '../src/types'; |
| 29 | + |
| 30 | + |
| 31 | +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT'] as const; |
| 32 | + |
| 33 | +/** `<kind> <qualifiedName>` for every symbol, file and imports aside. */ |
| 34 | +function symbols(result: ExtractionResult): string[] { |
| 35 | + return result.nodes |
| 36 | + .filter((n) => n.kind !== 'file' && n.kind !== 'import') |
| 37 | + .map((n) => `${n.kind} ${n.qualifiedName}`); |
| 38 | +} |
| 39 | + |
| 40 | +/** `<parent> > <child>` for every containment edge below the file. */ |
| 41 | +function containment(result: ExtractionResult): string[] { |
| 42 | + const byId = new Map(result.nodes.map((n) => [n.id, n])); |
| 43 | + return result.edges |
| 44 | + .filter((e) => e.kind === 'contains' && byId.get(e.source)?.kind !== 'file') |
| 45 | + .map((e) => `${byId.get(e.source)?.qualifiedName} > ${byId.get(e.target)?.qualifiedName}`); |
| 46 | +} |
| 47 | + |
| 48 | +describe('a type defined in a C/C++ declaration', () => { |
| 49 | + let savedEnv: Record<string, string | undefined> = {}; |
| 50 | + |
| 51 | + |
| 52 | + beforeEach(() => { |
| 53 | + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); |
| 54 | + resetKernelForTests(); |
| 55 | + }); |
| 56 | + |
| 57 | + afterEach(() => { |
| 58 | + for (const k of ENV_KEYS) { |
| 59 | + if (savedEnv[k] === undefined) delete process.env[k]; |
| 60 | + else process.env[k] = savedEnv[k]; |
| 61 | + } |
| 62 | + resetKernelForTests(); |
| 63 | + }); |
| 64 | + |
| 65 | + function extract(_backend: 'kernel', file: string, source: string, language: Language): ExtractionResult { |
| 66 | + delete process.env.CODEGRAPH_KERNEL; |
| 67 | + const result = tryKernelExtract(file, source, language); |
| 68 | + expect(result, `kernel extraction of ${file}`).not.toBeNull(); |
| 69 | + return result!; |
| 70 | + } |
| 71 | + |
| 72 | + const backends = ['kernel'] as const; |
| 73 | + |
| 74 | + for (const crlf of [false, true]) { |
| 75 | + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); |
| 76 | + const label = crlf ? ' (CRLF)' : ''; |
| 77 | + |
| 78 | + it.each(backends)(`C++ classes and structs beside their variables: %s${label}`, (backend) => { |
| 79 | + const source = [ |
| 80 | + 'namespace n {', |
| 81 | + 'struct Foo { int a; void f() {} } foo;', |
| 82 | + 'class Bar { void g() {} } bar, *pbar;', |
| 83 | + '}', |
| 84 | + '', |
| 85 | + ].join('\n'); |
| 86 | + const result = extract(backend, 'src/decl.cpp', eol(source), 'cpp'); |
| 87 | + expect(symbols(result)).toEqual([ |
| 88 | + 'struct n::Foo', |
| 89 | + 'method n::Foo::f', |
| 90 | + 'variable n::foo', |
| 91 | + 'class n::Bar', |
| 92 | + 'method n::Bar::g', |
| 93 | + 'variable n::bar', |
| 94 | + ]); |
| 95 | + expect(containment(result)).toEqual(['n::Foo > n::Foo::f', 'n::Bar > n::Bar::g']); |
| 96 | + }); |
| 97 | + |
| 98 | + it.each(backends)(`C structs, unions and enums; an unnamed one takes its variable's name: %s${label}`, (backend) => { |
| 99 | + const source = [ |
| 100 | + 'struct Foo { int a; } foo;', |
| 101 | + 'static struct { int argc; char **argv; } SPT;', |
| 102 | + 'static const struct { const char *name; int code; } errors[] = { { "none", 0 } };', |
| 103 | + 'struct { int x; } *first_point, second_point;', |
| 104 | + 'union { int i; float f; } scratch = { 0 };', |
| 105 | + 'enum { MODE_IDLE, MODE_BUSY } current_mode;', |
| 106 | + 'typedef struct Bar { int c; } Bar;', |
| 107 | + '', |
| 108 | + ].join('\n'); |
| 109 | + const result = extract(backend, 'src/decl.c', eol(source), 'c'); |
| 110 | + expect(symbols(result)).toEqual([ |
| 111 | + 'struct Foo', |
| 112 | + 'struct SPT', |
| 113 | + 'struct errors', |
| 114 | + 'constant errors', |
| 115 | + 'struct first_point', |
| 116 | + 'variable first_point', |
| 117 | + 'union scratch', |
| 118 | + 'variable scratch', |
| 119 | + 'enum current_mode', |
| 120 | + 'enum_member current_mode::MODE_IDLE', |
| 121 | + 'enum_member current_mode::MODE_BUSY', |
| 122 | + 'struct Bar', |
| 123 | + ]); |
| 124 | + }); |
| 125 | + |
| 126 | + it.each(backends)(`a type whose declaration declares no variable stays unnamed: %s${label}`, (backend) => { |
| 127 | + // A function's return type: naming it after the function would be wrong. |
| 128 | + const result = extract(backend, 'src/proto.c', eol('struct { int unused; } make_unnamed(void);\n'), 'c'); |
| 129 | + expect(symbols(result)).toEqual(['struct <anonymous>']); |
| 130 | + }); |
| 131 | + |
| 132 | + it.each(backends)(`the comment above the declaration documents the type: %s${label}`, (backend) => { |
| 133 | + const source = [ |
| 134 | + 'namespace app {', |
| 135 | + '/** The handler registry. */', |
| 136 | + 'struct Registry { int size() const { return 2; } } registry;', |
| 137 | + '}', |
| 138 | + '', |
| 139 | + ].join('\n'); |
| 140 | + const result = extract(backend, 'src/registry.cc', eol(source), 'cpp'); |
| 141 | + const docs = result.nodes |
| 142 | + .filter((n) => n.name === 'Registry' || n.name === 'registry') |
| 143 | + .map((n) => `${n.kind} ${n.docstring}`); |
| 144 | + expect(docs).toEqual(['struct The handler registry.', 'variable The handler registry.']); |
| 145 | + }); |
| 146 | + |
| 147 | + it.each(backends)(`an unnamed type in a function body takes its variable's name too: %s${label}`, (backend) => { |
| 148 | + const source = 'int hits(void) {\n static struct { int count; } local_stats;\n return ++local_stats.count;\n}\n'; |
| 149 | + const result = extract(backend, 'src/stats.c', eol(source), 'c'); |
| 150 | + expect(symbols(result)).toEqual(['function hits', 'struct hits::local_stats']); |
| 151 | + }); |
| 152 | + |
| 153 | + it.each(backends)(`a file the parser misreads: C keeps the type, C++ needs balanced braces: %s${label}`, (backend) => { |
| 154 | + // Native extraction recovers balanced C++ scopes in error trees. |
| 155 | + // error-extract hatch walks one anyway, and must agree. |
| 156 | + process.env.CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT = '1'; |
| 157 | + const c = extract(backend, 'src/spt.c', eol('static struct { int argc; } SPT;\nint broken( { return 1; }\n'), 'c'); |
| 158 | + expect(symbols(c)).toContain('struct SPT'); |
| 159 | + // A C++ file with errors is walked in the scopes its braces open, so the |
| 160 | + // class lands in its namespace. When the braces don't balance there are |
| 161 | + // no scopes to trust, and error recovery can close a namespace or a |
| 162 | + // class at the wrong `}`: the variable is kept and the class not walked. |
| 163 | + const config = 'namespace n {\nstruct Config { int retries; void apply() {} } config;\n}\n'; |
| 164 | + const balanced = extract(backend, 'src/config.cc', eol(`${config}int broken( { return 1; }\n`), 'cpp'); |
| 165 | + expect(symbols(balanced)).toEqual(['struct n::Config', 'method n::Config::apply', 'variable n::config']); |
| 166 | + const unbalanced = extract(backend, 'src/config2.cc', eol(`${config}int broken() { return 1;\n`), 'cpp'); |
| 167 | + expect(symbols(unbalanced)).toEqual(['variable n::config', 'function broken']); |
| 168 | + }); |
| 169 | + |
| 170 | + it.each(backends)(`a function value in the type's body is captured once, by the type: %s${label}`, (backend) => { |
| 171 | + const source = [ |
| 172 | + 'void on_open() {}', |
| 173 | + 'struct Handlers { void (*all[1])() = {&on_open}; } handlers;', |
| 174 | + '', |
| 175 | + ].join('\n'); |
| 176 | + const result = extract(backend, 'src/handlers.cc', eol(source), 'cpp'); |
| 177 | + const byId = new Map(result.nodes.map((n) => [n.id, n])); |
| 178 | + const refs = result.unresolvedReferences |
| 179 | + .filter((r) => r.referenceKind === 'function_ref') |
| 180 | + .map((r) => `${byId.get(r.fromNodeId)?.name} ${r.referenceName}`); |
| 181 | + expect(refs).toEqual(['Handlers on_open']); |
| 182 | + }); |
| 183 | + } |
| 184 | +}); |
| 185 | + |
| 186 | +describe('an indexed C/C++ project with types defined in declarations', () => { |
| 187 | + let root = ''; |
| 188 | + let cg: CodeGraph | undefined; |
| 189 | + |
| 190 | + afterEach(() => { |
| 191 | + cg?.destroy(); |
| 192 | + cg = undefined; |
| 193 | + if (root) fs.rmSync(root, { recursive: true, force: true }); |
| 194 | + root = ''; |
| 195 | + }); |
| 196 | + |
| 197 | + it('links base classes and calls to them, and finds an unnamed struct by its variable', async () => { |
| 198 | + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-declaration-types-')); |
| 199 | + const files: Record<string, string> = { |
| 200 | + 'src/registry.cc': [ |
| 201 | + 'namespace app {', |
| 202 | + 'struct Registry { int size() const { return 2; } } registry;', |
| 203 | + 'class Base { public: virtual int run() { return 1; } } base_instance;', |
| 204 | + 'class Worker : public Base { public: int run() override { return registry.size(); } };', |
| 205 | + 'int tick() { Worker w; return w.run() + registry.size(); }', |
| 206 | + '}', |
| 207 | + '', |
| 208 | + ].join('\n'), |
| 209 | + 'src/setproctitle.c': [ |
| 210 | + '/* Saved argv. */', |
| 211 | + 'static struct { int argc; char **argv; } SPT;', |
| 212 | + 'int spt_init(int argc) { SPT.argc = argc; return SPT.argc; }', |
| 213 | + '', |
| 214 | + ].join('\n'), |
| 215 | + }; |
| 216 | + for (const [rel, content] of Object.entries(files)) { |
| 217 | + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); |
| 218 | + fs.writeFileSync(path.join(root, rel), content); |
| 219 | + } |
| 220 | + cg = await CodeGraph.init(root, { index: true }); |
| 221 | + const graph = cg; |
| 222 | + const one = (qualifiedName: string) => { |
| 223 | + const found = graph.getNodesByName(qualifiedName.split('::').pop()!).filter((n) => n.qualifiedName === qualifiedName); |
| 224 | + expect(found, qualifiedName).toHaveLength(1); |
| 225 | + return found[0]!; |
| 226 | + }; |
| 227 | + const edges = (id: string, kind: 'calls' | 'extends') => |
| 228 | + graph |
| 229 | + .getIncomingEdgesTo([id], [kind]) |
| 230 | + .map((e) => graph.getNode(e.source)?.qualifiedName) |
| 231 | + .sort(); |
| 232 | + |
| 233 | + expect(edges(one('app::Registry::size').id, 'calls')).toEqual(['app::Worker::run', 'app::tick']); |
| 234 | + expect(edges(one('app::Base').id, 'extends')).toEqual(['app::Worker']); |
| 235 | + |
| 236 | + const spt = graph.searchNodes('SPT', { limit: 5 }).map((r) => `${r.node.kind} ${r.node.name}`); |
| 237 | + expect(spt).toContain('struct SPT'); |
| 238 | + expect(one('SPT').docstring).toBe('Saved argv.'); |
| 239 | + }, 60_000); |
| 240 | +}); |
0 commit comments