feat(extraction): add Nix language support with module-system option wiring (#324, #332 via #648 — carries #1084) (#1190)
Carries @TyceHerrman's #1084 as the functional base. Extraction + file wiring (imports/modules lists, callPackage), module-system option-path synthesizer, lexical-scope resolution gates, ABI-15 wasm rebuilt from upstream source. Validated on agenix, nix-darwin, home-manager, and nixpkgs (44,368 files, 3m49s, 1.30M nodes). Co-authored-by: Tyce Herrman <Tyce.Herrman@pm.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Tyce Herrman
Claude Fable 5
parent
99152212a9
commit
7f325134e0
@@ -211,6 +211,7 @@ export class QueryBuilder {
|
||||
deleteUnresolvedByNode?: SqliteStatement;
|
||||
getUnresolvedByName?: SqliteStatement;
|
||||
getNodesByName?: SqliteStatement;
|
||||
getNodesByNamePrefix?: SqliteStatement;
|
||||
getNodesByQualifiedNameExact?: SqliteStatement;
|
||||
getNodesByLowerName?: SqliteStatement;
|
||||
getUnresolvedCount?: SqliteStatement;
|
||||
@@ -890,6 +891,20 @@ export class QueryBuilder {
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes whose name starts with `prefix`, by index range scan (a LIKE would
|
||||
* skip idx_nodes_name under SQLite's default case-insensitive LIKE).
|
||||
*/
|
||||
getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
|
||||
if (!this.stmts.getNodesByNamePrefix) {
|
||||
this.stmts.getNodesByNamePrefix = this.db.prepare(
|
||||
'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name LIMIT ?'
|
||||
);
|
||||
}
|
||||
const rows = this.stmts.getNodesByNamePrefix.all(prefix, prefix + '', limit) as NodeRow[];
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes by exact qualified name match (uses idx_nodes_qualified_name index)
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
|
||||
solidity: 'tree-sitter-solidity.wasm',
|
||||
terraform: 'tree-sitter-terraform.wasm',
|
||||
arkts: 'tree-sitter-arkts.wasm',
|
||||
nix: 'tree-sitter-nix.wasm',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -138,6 +139,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
// see c-cpp.ts) blanks the CUDA-only tokens. (#387)
|
||||
'.cu': 'cpp',
|
||||
'.cuh': 'cpp',
|
||||
'.nix': 'nix',
|
||||
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
|
||||
// shape and emits SQL-statement nodes (other XML returns empty).
|
||||
'.xml': 'xml',
|
||||
@@ -303,7 +305,12 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
|
||||
// tarball's artifact. It extends the tree-sitter-javascript grammar the
|
||||
// same way tree-sitter-typescript does, adding `struct_declaration` and
|
||||
// the `arkui_component_expression` build() DSL.
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts')
|
||||
// Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
|
||||
// nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli
|
||||
// 0.25.10 (`generate` + `build --wasm`, ABI 15 — upstream's checked-in
|
||||
// parser.c is still ABI 13; all 54 upstream corpus tests pass on the
|
||||
// regenerated parser).
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts' || lang === 'nix')
|
||||
? path.join(__dirname, 'wasm', wasmFile)
|
||||
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
|
||||
const language = await WasmLanguage.load(wasmPath);
|
||||
@@ -518,6 +525,7 @@ export function getLanguageDisplayName(language: Language): string {
|
||||
luau: 'Luau',
|
||||
objc: 'Objective-C',
|
||||
solidity: 'Solidity',
|
||||
nix: 'Nix',
|
||||
yaml: 'YAML',
|
||||
twig: 'Twig',
|
||||
xml: 'XML',
|
||||
|
||||
@@ -35,6 +35,7 @@ import { erlangExtractor } from './erlang';
|
||||
import { solidityExtractor } from './solidity';
|
||||
import { terraformExtractor } from './terraform';
|
||||
import { arktsExtractor } from './arkts';
|
||||
import { nixExtractor } from './nix';
|
||||
|
||||
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
typescript: typescriptExtractor,
|
||||
@@ -67,4 +68,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
solidity: solidityExtractor,
|
||||
terraform: terraformExtractor,
|
||||
arkts: arktsExtractor,
|
||||
nix: nixExtractor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
||||
import { getNodeText } from '../tree-sitter-helpers';
|
||||
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
|
||||
|
||||
function unwrapVariableExpression(node: SyntaxNode): SyntaxNode {
|
||||
if (node.type !== 'variable_expression') return node;
|
||||
return node.namedChild(0) ?? node;
|
||||
}
|
||||
|
||||
function getCalleeName(node: SyntaxNode, source: string): string | null {
|
||||
let current = node;
|
||||
while (current.type === 'apply_expression') {
|
||||
const funcNode = current.childForFieldName('function') || current.namedChild(0);
|
||||
if (!funcNode) break;
|
||||
current = funcNode;
|
||||
}
|
||||
current = unwrapVariableExpression(current);
|
||||
if (current.type === 'identifier' || current.type === 'select_expression') {
|
||||
return getNodeText(current, source).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDirectCalleeName(node: SyntaxNode, source: string): string | null {
|
||||
let funcNode = node.childForFieldName('function') || node.namedChild(0);
|
||||
if (!funcNode) return null;
|
||||
funcNode = unwrapVariableExpression(funcNode);
|
||||
return getNodeText(funcNode, source).trim();
|
||||
}
|
||||
|
||||
function isStaticProjectPath(value: string): boolean {
|
||||
return (
|
||||
(value.startsWith('./') || value.startsWith('../')) &&
|
||||
!/[\s{}()[\];"'<>$]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function getStaticImportPath(argNode: SyntaxNode, source: string): string | null {
|
||||
let current = argNode;
|
||||
while (current.type === 'parenthesized_expression') {
|
||||
const inner = current.namedChild(0);
|
||||
if (!inner) break;
|
||||
current = inner;
|
||||
}
|
||||
|
||||
let text = getNodeText(current, source).trim();
|
||||
if (
|
||||
((text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))) &&
|
||||
text.length >= 2
|
||||
) {
|
||||
text = text.slice(1, -1);
|
||||
}
|
||||
|
||||
return isStaticProjectPath(text) ? text : null;
|
||||
}
|
||||
|
||||
function isReturnedAttrsetMember(node: SyntaxNode): boolean {
|
||||
let current: SyntaxNode | null = node;
|
||||
let seenReturnedAttrset = false;
|
||||
|
||||
while (current) {
|
||||
const parent: SyntaxNode | null = current.parent;
|
||||
if (!parent) break;
|
||||
|
||||
if (parent.type === 'let_expression') {
|
||||
const bodyNode = parent.childForFieldName('body') || parent.childForFieldName('expression');
|
||||
if (!bodyNode || !bodyNode.equals(current)) return false;
|
||||
}
|
||||
|
||||
if (parent.type === 'binding' && !current.equals(node)) return false;
|
||||
if (parent.type === 'formal_parameters' || parent.type === 'formals') return false;
|
||||
|
||||
if (
|
||||
parent.type === 'attrset' ||
|
||||
parent.type === 'rec_attrset' ||
|
||||
parent.type === 'attrset_expression' ||
|
||||
parent.type === 'rec_attrset_expression'
|
||||
) {
|
||||
seenReturnedAttrset = true;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return seenReturnedAttrset;
|
||||
}
|
||||
|
||||
function getCurriedParamsAndBody(node: SyntaxNode, source: string): { params: string[]; bodyNode: SyntaxNode | null } {
|
||||
const params: string[] = [];
|
||||
let current = node;
|
||||
|
||||
while (current.type === 'function_expression' && current.namedChildCount > 0) {
|
||||
const bodyNode = current.namedChild(current.namedChildCount - 1);
|
||||
if (!bodyNode) break;
|
||||
|
||||
const paramPart = source.substring(current.startIndex, bodyNode.startIndex).trim();
|
||||
const paramText = paramPart.endsWith(':') ? paramPart.slice(0, -1).trim() : paramPart;
|
||||
if (paramText) params.push(paramText);
|
||||
|
||||
if (bodyNode.type === 'function_expression') {
|
||||
current = bodyNode;
|
||||
} else {
|
||||
return { params, bodyNode };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
params,
|
||||
bodyNode: current.namedChildCount > 0 ? current.namedChild(current.namedChildCount - 1) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFunctionSignature(params: string[]): string {
|
||||
if (params.length === 0) return '()';
|
||||
if (params.length > 1) return params.join(' : ');
|
||||
|
||||
const [param] = params;
|
||||
if (!param) return '()';
|
||||
return param.startsWith('(') || param.includes('{') || param.includes('@') ? param : `(${param})`;
|
||||
}
|
||||
|
||||
function inheritedAttrs(node: SyntaxNode): SyntaxNode | null {
|
||||
return node.namedChildren.find((child) => child.type === 'inherited_attrs') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `callPackage ./pkg.nix { }` and `pkgs.callPackage ../tools/foo { }` — the
|
||||
* nixpkgs auto-wiring idiom — reference a file the same way `import` does.
|
||||
*/
|
||||
function isCallPackageName(name: string): boolean {
|
||||
return (
|
||||
name === 'callPackage' ||
|
||||
name === 'callPackages' ||
|
||||
name.endsWith('.callPackage') ||
|
||||
name.endsWith('.callPackages')
|
||||
);
|
||||
}
|
||||
|
||||
/** Innermost argument of a curried apply chain: `f a b` → `a`. */
|
||||
function getFirstApplyArgument(node: SyntaxNode): SyntaxNode | null {
|
||||
let inner = node;
|
||||
for (;;) {
|
||||
const fn = inner.childForFieldName('function') || inner.namedChild(0);
|
||||
if (fn && fn.type === 'apply_expression') {
|
||||
inner = fn;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return inner.childForFieldName('argument') || inner.namedChild(1);
|
||||
}
|
||||
|
||||
/** Import node + unresolved `imports` ref for a static project path. */
|
||||
function emitFileImport(ctx: ExtractorContext, importPath: string, anchorNode: SyntaxNode, source: string): void {
|
||||
const impNode = ctx.createNode('import', importPath, anchorNode, {
|
||||
signature: getNodeText(anchorNode, source).trim().slice(0, 100),
|
||||
});
|
||||
|
||||
if (impNode && ctx.nodeStack.length > 0) {
|
||||
const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1];
|
||||
if (fromNodeId) {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId,
|
||||
referenceName: importPath,
|
||||
referenceKind: 'imports',
|
||||
line: anchorNode.startPosition.row + 1,
|
||||
column: anchorNode.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const nixExtractor: LanguageExtractor = {
|
||||
functionTypes: [],
|
||||
classTypes: [],
|
||||
methodTypes: [],
|
||||
interfaceTypes: [],
|
||||
structTypes: [],
|
||||
enumTypes: [],
|
||||
typeAliasTypes: [],
|
||||
importTypes: [],
|
||||
callTypes: [],
|
||||
variableTypes: [],
|
||||
nameField: '',
|
||||
bodyField: '',
|
||||
paramsField: '',
|
||||
|
||||
visitNode: (node, ctx) => {
|
||||
const { source } = ctx;
|
||||
|
||||
if (node.type === 'binding') {
|
||||
const attrpath = node.childForFieldName('attrpath') || node.namedChild(0);
|
||||
if (!attrpath) return false;
|
||||
|
||||
const name = getNodeText(attrpath, source).trim();
|
||||
if (!name) return false;
|
||||
|
||||
const valueNode = node.childForFieldName('expression') || node.childForFieldName('value') || node.namedChild(1);
|
||||
if (!valueNode) return false;
|
||||
|
||||
if (valueNode.type === 'function_expression') {
|
||||
const { params, bodyNode } = getCurriedParamsAndBody(valueNode, source);
|
||||
const funcNode = ctx.createNode('function', name, node, {
|
||||
signature: formatFunctionSignature(params),
|
||||
isExported: isReturnedAttrsetMember(node),
|
||||
});
|
||||
|
||||
if (funcNode) {
|
||||
ctx.pushScope(funcNode.id);
|
||||
if (bodyNode) ctx.visitNode(bodyNode);
|
||||
ctx.popScope();
|
||||
}
|
||||
} else {
|
||||
const initValue = getNodeText(valueNode, source).slice(0, 100);
|
||||
ctx.createNode('variable', name, node, {
|
||||
signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined,
|
||||
isExported: isReturnedAttrsetMember(node),
|
||||
});
|
||||
|
||||
// NixOS/home-manager module lists: `imports = [ ./hardware.nix ../common ]`
|
||||
// (and the flake-era `modules = [ ./configuration.nix ]`) reference files
|
||||
// without an `import` call. Only literal `path_expression` entries count —
|
||||
// variables and interpolations stay dynamic (silent beats wrong).
|
||||
const finalSegment = name.split('.').pop();
|
||||
if ((finalSegment === 'imports' || finalSegment === 'modules') && valueNode.type === 'list_expression') {
|
||||
for (const child of valueNode.namedChildren) {
|
||||
if (child.type === 'path_expression') {
|
||||
const entryPath = getNodeText(child, source).trim();
|
||||
if (isStaticProjectPath(entryPath)) {
|
||||
emitFileImport(ctx, entryPath, child, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.visitNode(valueNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === 'function_expression') {
|
||||
const bodyNode = node.namedChild(node.namedChildCount - 1);
|
||||
if (bodyNode) ctx.visitNode(bodyNode);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === 'inherit' || node.type === 'inherit_from') {
|
||||
const attrs = inheritedAttrs(node);
|
||||
if (attrs) {
|
||||
for (const child of attrs.namedChildren) {
|
||||
const name = getNodeText(child, source).trim();
|
||||
if (name) {
|
||||
ctx.createNode('variable', name, child, {
|
||||
isExported: isReturnedAttrsetMember(child),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'inherited_attrs') ctx.visitNode(child);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === 'apply_expression') {
|
||||
const directCallee = getDirectCalleeName(node, source);
|
||||
const isDirectImport = directCallee === 'import' || directCallee === 'builtins.import';
|
||||
// Wrapper objects are re-created per access, so compare with .equals(),
|
||||
// never === — otherwise every level of a curried chain (`f a b`)
|
||||
// re-emits the same refs.
|
||||
const parentFn =
|
||||
node.parent?.type === 'apply_expression'
|
||||
? (node.parent.childForFieldName('function') ?? node.parent.namedChild(0))
|
||||
: null;
|
||||
const isCalleeOfParent = parentFn ? parentFn.equals(node) : false;
|
||||
|
||||
if (!(isCalleeOfParent && !isDirectImport)) {
|
||||
if (isDirectImport) {
|
||||
const argNode = node.childForFieldName('argument') || node.namedChild(1);
|
||||
const importPath = argNode ? getStaticImportPath(argNode, source) : null;
|
||||
|
||||
if (importPath) {
|
||||
emitFileImport(ctx, importPath, node, source);
|
||||
}
|
||||
} else {
|
||||
const calleeName = getCalleeName(node, source);
|
||||
if (calleeName && calleeName !== 'import' && calleeName !== 'builtins.import' && ctx.nodeStack.length > 0) {
|
||||
const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1];
|
||||
if (fromNodeId) {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId,
|
||||
referenceName: calleeName,
|
||||
referenceKind: 'calls',
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// `callPackage ./pkg.nix { }` loads the file like `import` does; the
|
||||
// first argument of the apply chain is the package file. Only a
|
||||
// literal static path counts (`callPackage pkgPath { }` stays dynamic).
|
||||
if (calleeName && isCallPackageName(calleeName)) {
|
||||
const firstArg = getFirstApplyArgument(node);
|
||||
const importPath = firstArg ? getStaticImportPath(firstArg, source) : null;
|
||||
if (importPath) {
|
||||
emitFileImport(ctx, importPath, node, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.namedChildren) {
|
||||
ctx.visitNode(child);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Executable
BIN
Binary file not shown.
@@ -953,6 +953,11 @@ export class CodeGraph {
|
||||
return this.queries.getNodesByName(name);
|
||||
}
|
||||
|
||||
/** Nodes whose name starts with `prefix` (index range scan, capped). */
|
||||
getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
|
||||
return this.queries.getNodesByNamePrefix(prefix, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search nodes by text
|
||||
*/
|
||||
|
||||
+29
-2
@@ -1944,11 +1944,18 @@ export class ToolHandler {
|
||||
}
|
||||
// Same token, non-callable synth endpoints (capped, precision-gated on an
|
||||
// actual heuristic edge so plain config constants never qualify).
|
||||
// Per-token sub-cap so one token's many endpoints (10 nix option writes
|
||||
// of `programs.git.enable` across test configs) can't fill the pool
|
||||
// before later tokens (`home.file`) get a slot.
|
||||
if (dynNamed.size < 12) {
|
||||
let tokenDyn = 0;
|
||||
for (const n of hits) {
|
||||
if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
|
||||
if (hasHeuristicEdge(n.id)) dynNamed.set(n.id, n);
|
||||
if (dynNamed.size >= 12) break;
|
||||
if (hasHeuristicEdge(n.id)) {
|
||||
dynNamed.set(n.id, n);
|
||||
tokenDyn++;
|
||||
}
|
||||
if (dynNamed.size >= 12 || tokenDyn >= 4) break;
|
||||
}
|
||||
}
|
||||
if (named.size > 40) break;
|
||||
@@ -4399,6 +4406,26 @@ export class ToolHandler {
|
||||
* results across all matching symbols (e.g., multiple classes with an `execute` method).
|
||||
*/
|
||||
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
|
||||
// Nix option paths: the declaration is stored as `options.<path>` and
|
||||
// config writes carry longer/quoted tails (`<path>."git/config".text`),
|
||||
// so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
|
||||
// no exact-name node and would degrade to bare-tail FTS soup — burying
|
||||
// the declaration hub the nix-option-path edges hang off. Resolve the
|
||||
// convention directly: declaration first, then the exact write, then a
|
||||
// capped prefix scan of write sites. Three index hits; non-nix graphs
|
||||
// fall straight through.
|
||||
if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
|
||||
const optionHits = [
|
||||
...cg.getNodesByName(`options.${symbol}`),
|
||||
...cg.getNodesByName(symbol),
|
||||
...cg.getNodesByNamePrefix(`${symbol}.`, 12),
|
||||
].filter((n) => n.language === 'nix');
|
||||
if (optionHits.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
|
||||
return { nodes, note: '' };
|
||||
}
|
||||
}
|
||||
let results = cg.searchNodes(symbol, { limit: 50 });
|
||||
|
||||
// Mirror the fallback in `findSymbol` for qualified queries — FTS
|
||||
|
||||
@@ -2854,6 +2854,182 @@ function erlangArityAt(src: string, openIdx: number): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nix module-system option wiring. A NixOS/home-manager/nix-darwin option is
|
||||
* DECLARED in one module (`options.launchd.user.agents = mkOption { ... }`)
|
||||
* and SET in others (`launchd.user.agents.yabai = { ... }` inside a module's
|
||||
* config) — the connection happens by option-path unification inside the
|
||||
* module-system evaluator, so there is no static call/import edge to follow
|
||||
* and flow questions ("how does services.yabai.enable become a launchd
|
||||
* service?") go dark at the module boundary.
|
||||
*
|
||||
* This pass links each config-write binding to the option declaration whose
|
||||
* path is the longest static-segment prefix of the write path. Precision gates:
|
||||
* - only STATIC segments participate: plain identifiers, plus quoted segments
|
||||
* (`"git/config"`, `"com.apple.dock"`) as opaque verbatim tokens that match
|
||||
* only quote-exactly; an interpolated (`${name}`) segment ends the prefix,
|
||||
* so dynamic paths never match beyond their static head;
|
||||
* - matched prefixes must be ≥2 segments: 1-segment paths would wrongly link
|
||||
* every package's `meta = { ... }` attrset to nixos's `options.meta`;
|
||||
* - a prefix declared in more than one file is ambiguous → no edge (a wrong
|
||||
* edge is worse than none);
|
||||
* - writes physically inside an options block are declaration internals
|
||||
* (types, defaults, examples), never config writes → excluded.
|
||||
* Both declaration spellings register: flat (`options.a.b = ...`) by name, and
|
||||
* nested (`options = { a.b = ...; }`) by line-span containment.
|
||||
*/
|
||||
function nixLeadingPlainSegments(name: string): string[] {
|
||||
const segs: string[] = [];
|
||||
let i = 0;
|
||||
const n = name.length;
|
||||
while (i < n) {
|
||||
if (name[i] === '"') {
|
||||
// Quoted segment — an opaque verbatim token (quotes kept, so it can
|
||||
// never collide with a plain identifier). `NSGlobalDomain."com.apple.
|
||||
// mouse.tapBehavior"` must match ITS OWN quoted declaration, not
|
||||
// whichever sibling registered the shared plain prefix first.
|
||||
let j = i + 1;
|
||||
while (j < n && name[j] !== '"') {
|
||||
if (name[j] === '\\') j++;
|
||||
j++;
|
||||
}
|
||||
if (j >= n) return segs; // unterminated — stop at the static head
|
||||
const tok = name.slice(i, j + 1);
|
||||
if (tok.includes('${')) return segs; // interpolated → dynamic → stop
|
||||
segs.push(tok);
|
||||
i = j + 1;
|
||||
if (i >= n) break;
|
||||
if (name[i] !== '.') return segs;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < n && name[j] !== '.') {
|
||||
if (name[j] === '"' || (name[j] === '$' && name[j + 1] === '{')) return segs;
|
||||
j++;
|
||||
}
|
||||
const seg = name.slice(i, j);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_'-]*$/.test(seg)) return segs;
|
||||
segs.push(seg);
|
||||
i = j + 1;
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): Promise<Edge[]> {
|
||||
type Rec = { id: string; filePath: string; startLine: number; endLine: number; segs: string[] };
|
||||
|
||||
// One streaming pass over nix bindings (variables + the odd function-valued
|
||||
// option); memory stays O(bindings-kept), not O(all nodes) (#610).
|
||||
const byFile = new Map<string, Rec[]>();
|
||||
let scanned = 0;
|
||||
for (const kind of ['variable', 'function'] as NodeKind[]) {
|
||||
for (const node of queries.iterateNodesByKind(kind)) {
|
||||
if ((++scanned & 0x3fff) === 0 && onYield) await onYield();
|
||||
if (node.language !== 'nix') continue;
|
||||
const segs = nixLeadingPlainSegments(node.name);
|
||||
if (segs.length === 0) continue;
|
||||
const rec: Rec = {
|
||||
id: node.id,
|
||||
filePath: node.filePath,
|
||||
startLine: node.startLine,
|
||||
endLine: node.endLine,
|
||||
segs,
|
||||
};
|
||||
const arr = byFile.get(node.filePath);
|
||||
if (arr) arr.push(rec);
|
||||
else byFile.set(node.filePath, [rec]);
|
||||
}
|
||||
}
|
||||
|
||||
// Per file: walk bindings outermost-first with a stack of active option
|
||||
// spans, composing nested declaration paths (`options = { services.foo = {
|
||||
// enable = mkOption ...; }; }` registers services.foo AND services.foo.enable).
|
||||
// An `options` binding nested inside another option span is a SUBMODULE's
|
||||
// own namespace (`attrsOf (submodule { options = ...; })`) — its internals
|
||||
// are not globally addressable, so the sentinel blocks registration below it
|
||||
// while still excluding the region from write candidates.
|
||||
const SUBMODULE = ' | ||||