add test for Julia

This commit is contained in:
Dongdong Kong 2026-05-24 19:33:18 +08:00
parent 56065efaa4
commit 0444e41f69
12 changed files with 410 additions and 33 deletions

View file

@ -30,6 +30,30 @@ import { juliaVariableConfig } from '../variable-extractors/configs/julia.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { juliaCallConfig } from '../call-extractors/configs/julia.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
const JULIA_CALL_RESULT = { kind: 'call' } as const;
const JULIA_SKIP_RESULT = { kind: 'skip' } as const;
function routeJuliaCall(calledName: string, callNode: SyntaxNode) {
// Skip signature-shape calls (e.g., `function run()` parsed as call_expression in signature).
if (callNode.parent?.type === 'signature') return JULIA_SKIP_RESULT;
if (calledName === 'include') {
const args =
callNode.childForFieldName('arguments') ??
callNode.namedChildren.find((n) => n.type === 'argument_list');
const literal = args?.namedChildren.find((n) => n.type === 'string_literal');
const content = literal?.namedChildren.find((n) => n.type === 'content');
const importPath = content?.text?.trim();
if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) {
return JULIA_SKIP_RESULT;
}
return { kind: 'import', importPath, isRelative: true } as const;
}
return JULIA_CALL_RESULT;
}
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@ -97,6 +121,7 @@ export const juliaProvider = defineLanguage({
typeConfig: juliaTypeConfig,
exportChecker: juliaExportChecker,
importResolver: createImportResolver(juliaImportConfig),
callRouter: routeJuliaCall,
importSemantics: 'wildcard-leaf',
callExtractor: createCallExtractor(juliaCallConfig),
fieldExtractor: createFieldExtractor(juliaFieldConfig),

View file

@ -2,9 +2,24 @@
// Verified against tree-sitter-julia grammar
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
function getJuliaParameterList(node: SyntaxNode): SyntaxNode | undefined {
const direct = node.childForFieldName('parameters');
if (direct) return direct;
const signature =
node.childForFieldName('signature') ?? node.namedChildren.find((n) => n.type === 'signature');
if (!signature) return undefined;
const headerCall = signature.namedChildren.find((n) => n.type === 'call_expression');
if (!headerCall) return undefined;
const args = headerCall.childForFieldName('arguments');
if (args) return args;
return headerCall.namedChildren.find((n) => n.type === 'argument_list');
}
/**
* Extract parameters from a Julia function definition.
*
@ -17,7 +32,7 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
* - Keyword: `; kw=default`
*/
function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
const paramList = getJuliaParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
@ -28,7 +43,13 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] {
switch (param.type) {
case 'identifier': {
params.push({ name: param.text, type: null, rawType: null, isOptional: false, isVariadic: false });
params.push({
name: param.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'typed_parameter': {
@ -62,7 +83,13 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] {
case 'splat_parameter': {
const nameNode = param.firstNamedChild;
if (nameNode) {
params.push({ name: nameNode.text, type: null, rawType: null, isOptional: false, isVariadic: true });
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
@ -78,18 +105,56 @@ function extractJuliaReturnType(node: SyntaxNode): string | undefined {
return retType?.text?.trim();
}
/**
* Extract function name from a Julia function node during parent-walk.
*
* Julia function_definition stores the name inside signature → call_expression:
* function run(x) ... end
* → (function_definition (signature (call_expression (identifier "run") ...)) ...)
*
* Short-form assignment functions are handled separately (assignment is not in
* FUNCTION_NODE_TYPES, so they don't appear in the parent-walk).
*/
function juliaExtractFunctionName(
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null {
if (node.type !== 'function_definition' && node.type !== 'macro_definition') return null;
// Navigate: function_definition → signature (first named child) → call_expression → identifier
const sig = node.namedChild(0);
if (sig?.type !== 'signature') return { funcName: null, label: 'Function' };
const callExpr = sig.namedChild(0);
if (callExpr?.type !== 'call_expression') return { funcName: null, label: 'Function' };
// The function name is the first child of the call_expression (an identifier)
const nameNode = callExpr.child(0);
const funcName = nameNode?.type === 'identifier' ? nameNode.text : null;
return { funcName, label: 'Function' };
}
export const juliaMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Julia,
// Julia methods are defined outside structs — no struct body method extraction
typeDeclarationNodes: ['struct_definition'],
methodNodeTypes: ['function_definition', 'short_function_definition'],
methodNodeTypes: ['function_definition'],
bodyNodeTypes: ['field_declaration_list'],
extractName(node) {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
// Navigate: function_definition → signature → call_expression → identifier
const sig = node.namedChild(0);
if (sig?.type === 'signature') {
const callExpr = sig.namedChild(0);
if (callExpr?.type === 'call_expression') {
const nameNode = callExpr.child(0);
if (nameNode?.type === 'identifier') return nameNode.text;
}
}
return undefined;
},
extractFunctionName: juliaExtractFunctionName,
extractReturnType: extractJuliaReturnType,
extractParameters: extractJuliaParameters,

View file

@ -1512,59 +1512,81 @@ export const DART_QUERIES = `
(type_identifier) @heritage.trait))) @heritage
`;
// Julia queries - works with tree-sitter-julia
// Julia queries — verified against tree-sitter-julia grammar.
//
// Key structural facts (no named fields for struct/abstract type children):
// struct Foo <: Bar → (struct_definition (type_head (binary_expression (identifier "Foo") ...)))
// struct Simple → (struct_definition (type_head (identifier "Simple")))
// abstract type A <: B → (abstract_definition (type_head (binary_expression ...)))
// module M → (module_definition name: (identifier "M")) ← has named field
// function f(x) → (function_definition (signature (call_expression . (identifier "f") ...)))
// f(x) = expr → (assignment . (call_expression . (identifier "f") ...) ...)
// macro @m(x) → (macro_definition (signature (call_expression . (identifier "m") ...)))
export const JULIA_QUERIES = `
; ── Structs (class-like types) ───────────────────────────────────────────────
; Simple struct (no supertype): (type_head (identifier))
(struct_definition
name: (identifier) @name) @definition.class
(type_head (identifier) @name)) @definition.class
; Struct with supertype: (type_head (binary_expression . identifier <: identifier))
(struct_definition
(type_head (binary_expression . (identifier) @name))) @definition.class
; ── Abstract types ────────────────────────────────────────────────────────────
(abstract_definition
name: (identifier) @name) @definition.class
(type_head (identifier) @name)) @definition.class
(abstract_definition
(type_head (binary_expression . (identifier) @name))) @definition.class
; ── Modules ───────────────────────────────────────────────────────────────────
(module_definition
name: (identifier) @name) @definition.module
; ── Function definitions ──────────────────────────────────────────────────────
; Full form: function f(args...) ... end
(function_definition
name: (identifier) @name) @definition.function
(signature (call_expression . (identifier) @name))) @definition.function
; ── Short-form function definitions: f(x) = expr ─────────────────────────────
(short_function_definition
name: (identifier) @name) @definition.function
; Short form: f(args...) = expr
; The leading . anchors call_expression as the first child (LHS), preventing
; false matches on the RHS call expression in the assignment.
(assignment .
(call_expression . (identifier) @name)) @definition.function
; ── Macro definitions ─────────────────────────────────────────────────────────
(macro_definition
name: (identifier) @name) @definition.function
(signature (call_expression . (identifier) @name))) @definition.function
; ── Import statements ─────────────────────────────────────────────────────────
(import_statement) @import
(import_statement (identifier) @import.source) @import
(using_statement) @import
(using_statement (identifier) @import.source) @import
; ── Call expressions ──────────────────────────────────────────────────────────
(call_expression
(identifier) @call.name) @call
; Free calls: f(args...)
(call_expression . (identifier) @call.name) @call
; Member calls: obj.method(args...) — (field_expression value: receiver (identifier method))
(call_expression
(field_expression
(identifier) @call.name)) @call
(field_expression value: (_) (identifier) @call.name)) @call
; ── Heritage: struct Foo <: Bar ───────────────────────────────────────────────
; ── Heritage: struct/abstract Foo <: Bar ──────────────────────────────────────
(struct_definition
name: (identifier) @heritage.class
supertype: (type_clause
(identifier) @heritage.extends)) @heritage
(type_head
(binary_expression
(identifier) @heritage.class
(identifier) @heritage.extends))) @heritage
(abstract_definition
name: (identifier) @heritage.class
supertype: (type_clause
(identifier) @heritage.extends)) @heritage
(type_head
(binary_expression
(identifier) @heritage.class
(identifier) @heritage.extends))) @heritage
; ── Variable assignments at module scope ──────────────────────────────────────
(assignment
left: (identifier) @name) @definition.variable
; ── Variable assignments ──────────────────────────────────────────────────────
; Simple assignment: x = expr (LHS is a plain identifier, not a call)
(assignment . (identifier) @name) @definition.variable
`;
import { SupportedLanguages } from 'gitnexus-shared';

View file

@ -25,14 +25,31 @@ const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment',
]);
function getJuliaParameterList(node: SyntaxNode): SyntaxNode | undefined {
const direct = node.childForFieldName('parameters');
if (direct) return direct;
const signature =
node.childForFieldName('signature') ?? node.namedChildren.find((n) => n.type === 'signature');
if (!signature) return undefined;
const headerCall = signature.namedChildren.find((n) => n.type === 'call_expression');
if (!headerCall) return undefined;
const args = headerCall.childForFieldName('arguments');
if (args) return args;
return headerCall.namedChildren.find((n) => n.type === 'argument_list');
}
/**
* Extract type annotations from function parameter lists into env.
* Julia: `function foo(x::Int, y::String)` → env.set("x", "Int")
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
const extractDeclaration: TypeBindingExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
if (node.type !== 'function_definition' && node.type !== 'short_function_definition') return;
const paramList = node.childForFieldName('parameters');
const paramList = getJuliaParameterList(node);
if (!paramList) return;
for (let i = 0; i < paramList.namedChildCount; i++) {
@ -51,7 +68,10 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
}
};
const extractParameter: ParameterExtractor = (_node: SyntaxNode, _env: Map<string, string>): void => {
const extractParameter: ParameterExtractor = (
_node: SyntaxNode,
_env: Map<string, string>,
): void => {
// Parameter types are handled by extractDeclaration above
};

View file

@ -0,0 +1,7 @@
function write_audit(x)
return x
end
function run()
write_audit(42)
end

View file

@ -0,0 +1,7 @@
function _internal()
return 1
end
function public_api()
return _internal()
end

View file

@ -0,0 +1,15 @@
abstract type AbstractAnimal end
abstract type Animal <: AbstractAnimal end
struct Dog <: Animal
name::String
end
struct Cat <: Animal
name::String
end
function speak(a::Animal)
println("...")
end

View file

@ -0,0 +1,5 @@
include("utils.jl")
function run()
write_audit(42)
end

View file

@ -0,0 +1,3 @@
function write_audit(x)
return x
end

View file

@ -0,0 +1,7 @@
struct User
id::Int
end
function process(user::User, limit::Int)
return limit
end

View file

@ -0,0 +1,26 @@
import { describe, it, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('Julia debug', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-calls'), () => {});
}, 60000);
it('shows all edges', () => {
const calls = getRelationships(result, 'CALLS');
console.log(
'CALLS:',
calls.map((c) => `${c.source} → ${c.target}`),
);
const fns = getNodesByLabel(result, 'Function');
console.log('Functions:', fns);
});
});

View file

@ -0,0 +1,175 @@
/**
* Julia: function definitions, struct/abstract type detection, heritage (<:),
* and function call resolution.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Function definitions and call resolution (single file)
// ---------------------------------------------------------------------------
describe('Julia function detection and call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-calls'), () => {});
}, 60000);
it('detects both functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('write_audit');
expect(fns).toContain('run');
});
it('emits CALLS edge: run → write_audit', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find((c) => c.source === 'run' && c.target === 'write_audit');
expect(edge).toBeDefined();
});
it('does not emit signature self-call edges', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'run' && c.target === 'run')).toBeUndefined();
expect(
calls.find((c) => c.source === 'write_audit' && c.target === 'write_audit'),
).toBeUndefined();
});
it('does not emit spurious CALLS edges from built-in calls', () => {
const calls = getRelationships(result, 'CALLS');
const builtinCalls = calls.filter((c) => c.target === 'println');
expect(builtinCalls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Struct and abstract type detection + heritage (<:)
// ---------------------------------------------------------------------------
describe('Julia struct and abstract type detection with heritage', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-heritage'), () => {});
}, 60000);
it('detects all class-like types (structs and abstract types)', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('AbstractAnimal');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
expect(classes).toContain('Cat');
});
it('detects the speak function', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('speak');
});
it('emits EXTENDS edge: Animal <: AbstractAnimal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Animal' && e.target === 'AbstractAnimal');
expect(edge).toBeDefined();
});
it('emits EXTENDS edge: Dog <: Animal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('emits EXTENDS edge: Cat <: Animal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Cat' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('emits exactly 3 EXTENDS edges', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_).sort()).toEqual([
'Animal → AbstractAnimal',
'Cat → Animal',
'Dog → Animal',
]);
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
});
describe('Julia include() import routing', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-include-import'), () => {});
}, 60000);
it('emits IMPORTS edge: app.jl → utils.jl from include()', () => {
const imports = getRelationships(result, 'IMPORTS');
const includeEdge = imports.find(
(e) => e.sourceFilePath === 'app.jl' && e.targetFilePath === 'utils.jl',
);
expect(includeEdge).toBeDefined();
});
it('resolves run → write_audit across include target', () => {
const calls = getRelationships(result, 'CALLS');
const edge = calls.find(
(c) => c.source === 'run' && c.target === 'write_audit' && c.targetFilePath === 'utils.jl',
);
expect(edge).toBeDefined();
});
});
describe('Julia typed parameter extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-typed-params'), () => {});
}, 60000);
it('extracts parameter metadata for process(user::User, limit::Int)', () => {
const fns = getNodesByLabelFull(result, 'Function');
const process = fns.find((n) => n.name === 'process');
expect(process).toBeDefined();
expect(process?.properties.parameterCount).toBe(2);
expect(process?.properties.requiredParameterCount).toBe(2);
expect(process?.properties.parameterTypes).toEqual(['User', 'Int']);
});
});
describe('Julia export visibility in top-level scripts', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-export-visibility'), () => {});
}, 60000);
it('marks underscore-prefixed function as internal', () => {
const fns = getNodesByLabelFull(result, 'Function');
const internal = fns.find((n) => n.name === '_internal');
expect(internal).toBeDefined();
expect(internal?.properties.isExported).toBe(false);
});
it('marks non-underscore function as exported', () => {
const fns = getNodesByLabelFull(result, 'Function');
const api = fns.find((n) => n.name === 'public_api');
expect(api).toBeDefined();
expect(api?.properties.isExported).toBe(true);
});
});