perf(csharp): scanner fallback for namespace siblings on the worker path

Worker threads can't return tree-sitter Trees across MessageChannels, so
the cross-phase tree cache is empty for worker-parsed files. The C#
same-namespace pass (populateCsharpNamespaceSiblings -> extractFileStructure)
then re-parsed every file with tree-sitter to find namespace / using-static
nodes — effectively parsing a large solution a second time during scope
resolution.

Add a line-scanner fallback (extractCsharpStructureViaScanner) used only
when no cached Tree is available, mirroring PHP's fix for issue #1741. It
extracts the same namespaces / usingStaticPaths the AST walk produces for
the common line-anchored forms (file-scoped + block namespaces, plain /
global / aliased `using static`). The AST walk stays authoritative on the
sequential / warm-cache path.

Micro-benchmark over 3000 synthetic files: scanner is ~188x faster than
parse+walk (0.001 vs 0.251 ms/file) with identical output on the parity
spot-check; real-world files are larger, so the worker-path saving is
bigger. Adds csharp-namespace-extraction.test.ts (12 cases) covering all
declaration forms plus negative cases (using var, plain using, comments).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-05-29 14:13:41 +00:00
parent 9bda164ca7
commit d6a4bb983d
2 changed files with 126 additions and 14 deletions

View file

@ -28,17 +28,19 @@
* aliased `using static X = Y.Z;`, attributed namespace declarations,
* and preprocessor-guarded declarations correctly because the
* tree-sitter grammar parses them as real nodes (not textual
* coincidences).
* coincidences). When the orchestrator's `treeCache` has no Tree for a
* file — the worker path, where native Trees can't cross MessageChannels
* — `extractFileStructure` falls back to a line scanner rather than
* re-parsing every file from scratch (that re-parse dominated worker-mode
* scope-resolution time). See `extractCsharpStructureViaScanner`.
*/
import type { SyntaxNode } from 'tree-sitter';
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCsharpParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
interface CsharpFileStructure {
export interface CsharpFileStructure {
/** Declared namespace names in file source order. Empty array means
* the file has no `namespace X;` / `namespace X { }` declaration
* and sits in the default (global) namespace. */
@ -48,18 +50,57 @@ interface CsharpFileStructure {
readonly usingStaticPaths: readonly string[];
}
/** Build a structural view of a C# file by walking the tree-sitter
* AST. Prefers `cachedTree` (handed in via `treeCache`) so we don't
* re-parse files the orchestrator already parsed for `extractParsedFile`;
* falls back to a fresh parse on cache miss. Parser singleton is
* shared across calls. */
// Line-anchored scanners for the worker-path fallback (see
// `extractCsharpStructureViaScanner`). Anchored at line start (after
// indentation) so `// namespace X` comments and string literals don't
// match — the same false-positive trade-off PHP's scanner accepts.
const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
// `global using static`, plain `using static`, and the aliased
// `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
// the optional `Alias =` is skipped and only the dotted path captured).
const CS_USING_STATIC_RE =
/^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/;
/** Line-scanner used when no cached tree is available (worker-parsed
* files can't transfer native tree-sitter Trees across MessageChannels,
* so `treeCache` is empty for them). Re-parsing every C# file here with
* tree-sitter was the dominant scope-resolution cost on large worker-mode
* runs — for a multi-thousand-file solution this loop alone re-parsed the
* whole repo a second time. The scanner extracts the same `namespaces` /
* `usingStaticPaths` the AST walk produces for the common (line-anchored)
* declarations, trading exact handling of the rare cases (declarations
* split across lines, namespace keywords inside block comments/strings)
* for eliminating those re-parses. Mirrors PHP's `extractNamespaceViaScanner`
* (issue #1741). */
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
const namespaces: string[] = [];
const usingStaticPaths: string[] = [];
for (const line of content.split('\n')) {
const ns = CS_NAMESPACE_RE.exec(line);
if (ns !== null) {
namespaces.push(ns[1]!);
continue;
}
const us = CS_USING_STATIC_RE.exec(line);
if (us !== null) usingStaticPaths.push(us[1]!);
}
return { namespaces, usingStaticPaths };
}
/** Build a structural view of a C# file. Prefers `cachedTree` (handed in
* via `treeCache`) and walks the tree-sitter AST — the authoritative
* path that sees `global using static`, aliased `using static X = Y.Z;`,
* attributed namespace declarations, and preprocessor-guarded nodes
* correctly. On cache miss (worker-parsed files, whose native Trees
* can't cross MessageChannels) it falls back to the line scanner instead
* of a fresh tree-sitter parse — the parse here dominated worker-mode
* scope-resolution time. Parser singleton is shared across calls. */
function extractFileStructure(content: string, cachedTree: unknown): CsharpFileStructure {
if (!cachedTree) {
return extractCsharpStructureViaScanner(content);
}
type CsharpTree = ReturnType<ReturnType<typeof getCsharpParser>['parse']>;
const tree =
(cachedTree as CsharpTree | undefined) ??
parseSourceSafe(getCsharpParser(), content, undefined, {
bufferSize: getTreeSitterBufferSize(content),
});
const tree = cachedTree as CsharpTree;
const namespaces: string[] = [];
const usingStaticPaths: string[] = [];

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { extractCsharpStructureViaScanner } from '../../src/core/ingestion/languages/csharp/namespace-siblings.js';
// Scanner fallback used on the worker path, where native tree-sitter Trees
// can't cross MessageChannels so `treeCache` is empty. It must reproduce
// the AST walk's `namespaces` / `usingStaticPaths` for the common
// line-anchored declaration forms (see namespace-siblings.ts).
describe('extractCsharpStructureViaScanner', () => {
it('extracts a file-scoped namespace declaration', () => {
const src = `namespace App.Models;\n\npublic class User {}`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Models']);
});
it('extracts a block namespace declaration', () => {
const src = `namespace App.Services\n{\n public class Svc {}\n}`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Services']);
});
it('extracts multiple namespaces in source order', () => {
const src = `namespace A.One\n{\n}\nnamespace A.Two\n{\n}`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['A.One', 'A.Two']);
});
it('returns empty namespaces for a global (no-namespace) file', () => {
const src = `public class Global {}\n`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual([]);
});
it('captures a plain `using static` path', () => {
const src = `using static System.Math;\nnamespace App;`;
const out = extractCsharpStructureViaScanner(src);
expect(out.usingStaticPaths).toEqual(['System.Math']);
expect(out.namespaces).toEqual(['App']);
});
it('captures a `global using static` path', () => {
const src = `global using static App.Utils.Logger;\n`;
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual(['App.Utils.Logger']);
});
it('captures the RHS path of an aliased `using static`', () => {
const src = `using static M = App.Utils.MathUtils;\n`;
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual(['App.Utils.MathUtils']);
});
it('does not treat a plain `using` directive as using-static', () => {
const src = `using System.Collections.Generic;\nusing App.Models;\n`;
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual([]);
});
it('does not treat a `using var`/`using (...)` statement as using-static', () => {
const src = `using var stream = File.Open(p);\nusing (var x = Get()) { }\n`;
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual([]);
});
it('ignores a `// namespace X` line comment', () => {
const src = `// namespace Fake.Comment;\nnamespace App.Real;`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
});
it('handles indentation before declarations', () => {
const src = `\t\tnamespace App.Indented;\n`;
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Indented']);
});
it('handles an empty file', () => {
const out = extractCsharpStructureViaScanner('');
expect(out.namespaces).toEqual([]);
expect(out.usingStaticPaths).toEqual([]);
});
});