mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 03:08:20 +08:00
fix(explore): a symbol written with its package resolves, like refresh.Discovery.refresh (#2750)
Explore's token resolution, codegraph_node's lookup and the callers, callees and impact lookups dropped every Go symbol written the way Go code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier` and `gin.Context.Next` resolved to nothing, and so did `binding.Default`. Three gaps: - A qualified name was matched against the qualifiedName, which holds the type and not the package, or entirely against the path, which holds the package and not the type, never as a package followed by a type. A new matcher stage reads `pkg.Type.method` as leading qualifiers that name the package by path segment and trailing ones that end the node's scope. A Python `pkg.mod.Class.method` and a Rust `module::Type::method` read the same way. - A module's root package has no directory: gin's `context.go` is `package gin`. A Go package qualifier now also matches the package the file declares, read from its `package` clause (src/graph/go-package.ts). That covers versioned `v2/` directories and generated packages like prometheus's `io_prometheus_client` as well. The clause is read last and only where it can decide, a top-level function or a node whose scope already ends with the written type, so a common name's hundreds of definitions cost no file reads. - Explore and codegraph_node took a qualified token's matches from the full-text search's top 50 only, and for gin's `binding.Default` those were three `binding/default_validator*.go` files. Through one shared helper they now fall back to the exact-name index when the search has no match; the CLI's lookup already started there. The new stage runs only after the existing ones fail, and the search keeps its order, so a name that resolved before keeps its result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fc293f0081
commit
f5dfa21fef
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The package a Go file declares — the name a `pkg.Type.method` query writes.
|
||||
*
|
||||
* It is usually the name of the file's directory, which is why a package
|
||||
* qualifier is first compared with the path. Not always: a module's root
|
||||
* package has no directory of its own (gin's `context.go` is `package gin` at
|
||||
* the top of the repository), and a versioned directory (`v2/`) keeps its
|
||||
* parent's name. The `package` clause is the authority, and it is not in the
|
||||
* index, so it is read from the file when a lookup needs it.
|
||||
*/
|
||||
|
||||
import { closeSync, openSync, readSync } from 'fs';
|
||||
import { validatePathWithinRoot } from '../utils';
|
||||
|
||||
/** The package a Go file declares, by its project-relative path; null when unknown. */
|
||||
export type GoPackageOf = (filePath: string) => string | null;
|
||||
|
||||
/** Enough of a file to get past any license header and build tags. */
|
||||
const HEAD_BYTES = 8192;
|
||||
|
||||
/**
|
||||
* The name in a Go source's `package` clause: the first token after its
|
||||
* comments and build constraints. Null when there is none.
|
||||
*/
|
||||
export function goPackageClause(source: string): string | null {
|
||||
const code = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, '');
|
||||
return /^\s*package\s+([A-Za-z_]\w*)/.exec(code)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** `goPackageReader` for a graph's project; none when the root can't be had. */
|
||||
export function goPackageReaderFor(cg: { getProjectRoot(): string }): GoPackageOf | undefined {
|
||||
try {
|
||||
return goPackageReader(cg.getProjectRoot());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `GoPackageOf` for files under `projectRoot`, reading each file's head once.
|
||||
* A file it cannot read declares no package, so the qualifier does not match.
|
||||
*/
|
||||
export function goPackageReader(projectRoot: string): GoPackageOf {
|
||||
const cache = new Map<string, string | null>();
|
||||
return (filePath) => {
|
||||
const hit = cache.get(filePath);
|
||||
if (hit !== undefined) return hit;
|
||||
let pkg: string | null = null;
|
||||
const absPath = validatePathWithinRoot(projectRoot, filePath);
|
||||
if (absPath) {
|
||||
try {
|
||||
const fd = openSync(absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(HEAD_BYTES);
|
||||
const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
|
||||
pkg = goPackageClause(buf.toString('utf8', 0, n));
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
pkg = null;
|
||||
}
|
||||
}
|
||||
cache.set(filePath, pkg);
|
||||
return pkg;
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,8 @@ import type CodeGraph from '../index';
|
||||
import type { Node, Edge } from '../types';
|
||||
import { isTestFile } from '../search/query-utils';
|
||||
|
||||
import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
|
||||
import { qualifiedSymbolMatches } from './symbol-lookup';
|
||||
import { goPackageReaderFor } from './go-package';
|
||||
|
||||
// Preserve the existing imports while sharing the matcher with the CLI and MCP.
|
||||
export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
|
||||
@@ -79,15 +80,9 @@ export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[];
|
||||
// ranking a differently-cased sibling above the real node (#1473 Fetch).
|
||||
exactNodes = cg.getNodesByName(symbol);
|
||||
} else {
|
||||
let results = cg.searchNodes(symbol, { limit: 50 });
|
||||
// Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
|
||||
if (results.length === 0) {
|
||||
const tail = lastQualifierPart(symbol);
|
||||
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
|
||||
}
|
||||
exactNodes = results
|
||||
.filter((r) => matchesSymbol(r.node, symbol))
|
||||
.map((r) => r.node);
|
||||
// Shared with codegraph_node's lookup: search rank first, the complete
|
||||
// exact-name index when the search's cut misses the definition.
|
||||
exactNodes = qualifiedSymbolMatches(cg, symbol, goPackageReaderFor(cg));
|
||||
}
|
||||
|
||||
if (exactNodes.length === 0) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import type { Node } from '../types';
|
||||
import type { GoPackageOf } from './go-package';
|
||||
|
||||
/** Rust path prefixes that name no directory (`crate::x`, `super::y`). */
|
||||
export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
||||
@@ -57,9 +58,11 @@ function canonicalScope(text: string): string {
|
||||
* Bare queries match the name. Qualified queries are checked against the
|
||||
* qualifiedName under both separator conventions, then — for languages whose
|
||||
* hierarchy lives in the file path rather than the name (Rust modules, Python
|
||||
* packages) — against the path.
|
||||
* packages, Go packages) — against the path, and last as a package followed by
|
||||
* the scope inside it. `goPackageOf` gives a Go file's declared package, for a
|
||||
* package qualifier that names no directory.
|
||||
*/
|
||||
export function matchesSymbol(node: Node, symbol: string): boolean {
|
||||
export function matchesSymbol(node: Node, symbol: string, goPackageOf?: GoPackageOf): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3`): when the node's qualifiedName
|
||||
// carries an arity (#1610) the written arity must match exactly, and the rest
|
||||
// of the comparison runs on the arity-less spelling. A node with no arity
|
||||
@@ -109,9 +112,61 @@ export function matchesSymbol(node: Node, symbol: string): boolean {
|
||||
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
||||
if (containerHints.length === 0) return false;
|
||||
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
||||
return containerHints.every((hint) =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
|
||||
);
|
||||
const inPath = (hint: string): boolean =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint);
|
||||
if (containerHints.every(inPath)) return true;
|
||||
|
||||
// Stage 3: a package, then the scope inside it. `refresh.Discovery.refresh` is
|
||||
// method `refresh` of type `Discovery` in Go package `refresh` (a Python
|
||||
// `pkg.mod.Class.method` reads the same way): the type is in the
|
||||
// qualifiedName and the package in the path, so neither stage above reads the
|
||||
// whole name. The leading qualifiers must name the package as stage 2 does,
|
||||
// the rest must end the node's scope.
|
||||
const scope = canonicalScope(node.qualifiedName).split('.').slice(0, -1);
|
||||
const splits: number[] = [];
|
||||
for (let k = 1; k < containerHints.length; k++) {
|
||||
const inner = containerHints.slice(k);
|
||||
const offset = scope.length - inner.length;
|
||||
if (offset < 0 || !inner.every((hint, i) => scope[offset + i] === hint)) continue;
|
||||
if (containerHints.slice(0, k).every(inPath)) return true;
|
||||
splits.push(k);
|
||||
}
|
||||
|
||||
// Last, a Go qualifier names the package the file DECLARES: its directory as a
|
||||
// rule, but a module's root package has no directory (gin's `context.go` is
|
||||
// `package gin`), and a `v2/` directory keeps its parent's name. The clause
|
||||
// is read only where it can decide — a top-level function (`gin.New`), or a
|
||||
// node whose scope already ends with the written type — so a common name's
|
||||
// hundreds of definitions cost no file reads.
|
||||
if (node.language !== 'go' || !goPackageOf || (scope.length > 0 && splits.length === 0)) return false;
|
||||
const declared = goPackageOf(node.filePath);
|
||||
if (!declared) return false;
|
||||
const namesPackage = (hint: string): boolean => hint === declared || inPath(hint);
|
||||
if (scope.length === 0 && containerHints.every(namesPackage)) return true;
|
||||
return splits.some((k) => containerHints.slice(0, k).every(namesPackage));
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes a QUALIFIED query names, in search-rank order: the full-text
|
||||
* search's top hits that satisfy the matcher, else every exact-name definition
|
||||
* of the last part that does. The search ranks and truncates, so a definition
|
||||
* can sit below its cut: for gin's `binding.Default` it returned three
|
||||
* `binding/default_validator*.go` files and not the function, and the query
|
||||
* named nothing. The exact-name index is complete.
|
||||
*/
|
||||
export function qualifiedSymbolMatches(
|
||||
cg: SymbolLookupHost,
|
||||
symbol: string,
|
||||
goPackageOf?: GoPackageOf
|
||||
): Node[] {
|
||||
const tail = lastQualifierPart(symbol);
|
||||
let hits = cg.searchNodes(symbol, { limit: 50 });
|
||||
// FTS strips colons, so `stage_apply::run` searches the literal
|
||||
// `stage_applyrun` and finds nothing: search the bare last part instead.
|
||||
if (hits.length === 0 && tail && tail !== symbol) hits = cg.searchNodes(tail, { limit: 50 });
|
||||
const ranked = hits.map((h) => h.node).filter((n) => matchesSymbol(n, symbol, goPackageOf));
|
||||
if (ranked.length > 0 || !tail) return ranked;
|
||||
return cg.getNodesByName(tail).filter((n) => matchesSymbol(n, symbol, goPackageOf));
|
||||
}
|
||||
|
||||
/** The slice of CodeGraph a symbol lookup needs — keeps this module testable. */
|
||||
@@ -170,17 +225,21 @@ export function groupDefinitions(
|
||||
* happened to rank first. FTS candidates still have to satisfy the matcher;
|
||||
* partial or mistyped names must never select the top fuzzy hit (#1473).
|
||||
*/
|
||||
export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolLookupResult {
|
||||
export function lookupSymbolNodes(
|
||||
cg: SymbolLookupHost,
|
||||
symbol: string,
|
||||
goPackageOf?: GoPackageOf
|
||||
): SymbolLookupResult {
|
||||
const qualified = isQualifiedSymbol(symbol);
|
||||
|
||||
// Exact-name index, then filter by the qualifier the user actually wrote.
|
||||
const tail = qualified ? lastQualifierPart(symbol) : symbol;
|
||||
let nodes = tail ? cg.getNodesByName(tail) : [];
|
||||
if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol));
|
||||
if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol, goPackageOf));
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const hits = cg.searchNodes(symbol, { limit: 50 }).map((h) => h.node);
|
||||
const exact = hits.filter((n) => matchesSymbol(n, symbol));
|
||||
const exact = hits.filter((n) => matchesSymbol(n, symbol, goPackageOf));
|
||||
if (exact.length > 0) {
|
||||
nodes = exact;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user