Skip to content

Commit 66606f4

Browse files
committed
test with Julia pkg: SoilDifferentialEquations.jl
1 parent 0d50a43 commit 66606f4

2 files changed

Lines changed: 244 additions & 10 deletions

File tree

__tests__/extraction.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3981,4 +3981,80 @@ end
39813981
expect(call).toBeDefined();
39823982
});
39833983
});
3984+
3985+
describe('Short-form function definitions', () => {
3986+
it('should extract short assignment-form functions', () => {
3987+
const code = `
3988+
add(x, y) = x + y
3989+
distance(a::Point, b::Point) = sqrt((a.x - b.x)^2)
3990+
`;
3991+
const result = extractFromSource('short.jl', code);
3992+
const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
3993+
expect(funcs).toContain('add');
3994+
expect(funcs).toContain('distance');
3995+
const add = result.nodes.find((n) => n.name === 'add');
3996+
expect(add?.signature).toBe('(x, y)');
3997+
});
3998+
});
3999+
4000+
describe('Struct field extraction', () => {
4001+
it('should extract typed and untyped fields from struct body', () => {
4002+
const code = `
4003+
struct Point
4004+
x::Float64
4005+
y::Float64
4006+
label
4007+
end
4008+
`;
4009+
const result = extractFromSource('structs.jl', code);
4010+
const fields = result.nodes.filter((n) => n.kind === 'field').map((n) => n.name);
4011+
expect(fields).toContain('x');
4012+
expect(fields).toContain('y');
4013+
expect(fields).toContain('label');
4014+
const x = result.nodes.find((n) => n.kind === 'field' && n.name === 'x');
4015+
expect(x?.signature).toBe('x::Float64');
4016+
});
4017+
});
4018+
4019+
describe('include() as relative import', () => {
4020+
it('should convert include("file.jl") to an import node', () => {
4021+
const code = `include("utils.jl")`;
4022+
const result = extractFromSource('app.jl', code);
4023+
const imp = result.nodes.find((n) => n.kind === 'import' && n.name === 'utils');
4024+
expect(imp).toBeDefined();
4025+
expect(imp?.signature).toBe('include("utils.jl")');
4026+
const ref = result.unresolvedReferences.find(
4027+
(r) => r.referenceKind === 'imports' && r.referenceName === 'utils'
4028+
);
4029+
expect(ref).toBeDefined();
4030+
});
4031+
});
4032+
4033+
describe('Module extraction', () => {
4034+
it('should extract module_definition as a namespace node', () => {
4035+
const code = `
4036+
module MyPkg
4037+
function foo() end
4038+
end
4039+
`;
4040+
const result = extractFromSource('pkg.jl', code);
4041+
const mod = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'MyPkg');
4042+
expect(mod).toBeDefined();
4043+
const foo = result.nodes.find((n) => n.kind === 'function' && n.name === 'foo');
4044+
expect(foo).toBeDefined();
4045+
});
4046+
});
4047+
4048+
describe('Qualified method names', () => {
4049+
it('should extract Base.getindex-style qualified function names', () => {
4050+
const code = `
4051+
function Base.getindex(x::Vector{T}, i::Int) where T
4052+
return x[i]
4053+
end
4054+
`;
4055+
const result = extractFromSource('ext.jl', code);
4056+
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'Base.getindex');
4057+
expect(fn).toBeDefined();
4058+
});
4059+
});
39844060
});

src/extraction/languages/julia.ts

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { LanguageExtractor } from '../tree-sitter-types';
1010
* call_expression → `function foo(args...) end`
1111
* typed_expression → `function foo(x)::T end` (return type annotation on sig)
1212
* where_expression → `function foo(x::T) where T end`
13+
* field_expression → `function Base.getindex(x) end` (qualified name)
1314
*/
1415
function extractFunctionName(signatureNode: SyntaxNode, source: string): string | null {
1516
// Unwrap the tree-sitter 'signature' wrapper node
@@ -18,7 +19,7 @@ function extractFunctionName(signatureNode: SyntaxNode, source: string): string
1819
if (inner) return extractFunctionName(inner, source);
1920
return getNodeText(signatureNode, source);
2021
}
21-
if (signatureNode.type === 'identifier') {
22+
if (signatureNode.type === 'identifier' || signatureNode.type === 'field_expression') {
2223
return getNodeText(signatureNode, source);
2324
}
2425
if (signatureNode.type === 'call_expression') {
@@ -83,7 +84,7 @@ function extractFunctionSignature(signatureNode: SyntaxNode, source: string): st
8384

8485
/**
8586
* Extract the name from a Julia type_head node (used in struct/abstract definitions).
86-
* type_head can be: identifier, call_expression (for parametric types), binary_expression
87+
* type_head can be: identifier, parametrized_type_expression (Foo{T}), binary_expression
8788
* (for subtype declarations like `Foo <: Bar`), etc.
8889
*/
8990
function extractTypeName(typeHeadNode: SyntaxNode, source: string): string | null {
@@ -114,6 +115,51 @@ function extractTypeName(typeHeadNode: SyntaxNode, source: string): string | nul
114115
return getNodeText(typeHeadNode, source);
115116
}
116117

118+
/**
119+
* Extract field name from a struct field node.
120+
* identifier → plain untyped field: `label`
121+
* typed_expression → typed field: `x::Float64`
122+
* assignment → field with default: `x::Int = 1` or `flag = false`
123+
*/
124+
function extractFieldName(node: SyntaxNode): string | null {
125+
if (node.type === 'identifier') return node.text;
126+
if (node.type === 'typed_expression') return node.firstNamedChild?.text ?? null;
127+
if (node.type === 'assignment') {
128+
const lhs = node.firstNamedChild;
129+
if (lhs?.type === 'typed_expression') return lhs.firstNamedChild?.text ?? null;
130+
if (lhs?.type === 'identifier') return lhs.text;
131+
}
132+
return null;
133+
}
134+
135+
/**
136+
* Extract field type annotation from a struct field node.
137+
*/
138+
function extractFieldType(node: SyntaxNode, source: string): string | undefined {
139+
if (node.type === 'typed_expression') {
140+
const typeNode = node.namedChild(1);
141+
return typeNode ? getNodeText(typeNode, source).trim() : undefined;
142+
}
143+
if (node.type === 'assignment') {
144+
const lhs = node.firstNamedChild;
145+
if (lhs?.type === 'typed_expression') {
146+
const typeNode = lhs.namedChild(1);
147+
return typeNode ? getNodeText(typeNode, source).trim() : undefined;
148+
}
149+
}
150+
return undefined;
151+
}
152+
153+
/**
154+
* True when node is a direct struct body field (not the type_head).
155+
*/
156+
function isStructField(node: SyntaxNode): boolean {
157+
const parent = node.parent;
158+
if (!parent) return false;
159+
// Fields live inside the block body of a struct_definition
160+
return parent.type === 'block' && parent.parent?.type === 'struct_definition';
161+
}
162+
117163
export const juliaExtractor: LanguageExtractor = {
118164
functionTypes: ['function_definition', 'macro_definition'],
119165
classTypes: [],
@@ -138,11 +184,9 @@ export const juliaExtractor: LanguageExtractor = {
138184
*/
139185
getName: (node, source) => {
140186
if (node.type === 'function_definition' || node.type === 'macro_definition') {
141-
// signature is always the second named child after the 'function'/'macro' keyword
142187
for (let i = 0; i < node.namedChildCount; i++) {
143188
const child = node.namedChild(i);
144189
if (!child) continue;
145-
// The signature node is the first non-keyword named child
146190
if (child.type !== 'block') {
147191
return extractFunctionName(child, source);
148192
}
@@ -151,7 +195,6 @@ export const juliaExtractor: LanguageExtractor = {
151195
}
152196

153197
if (node.type === 'struct_definition') {
154-
// Find type_head child
155198
for (let i = 0; i < node.namedChildCount; i++) {
156199
const child = node.namedChild(i);
157200
if (!child) continue;
@@ -163,14 +206,13 @@ export const juliaExtractor: LanguageExtractor = {
163206
}
164207

165208
if (node.type === 'abstract_definition') {
166-
// abstract type <type_head> end — first named child is type_head
167209
const typeHead = node.namedChild(0);
168210
if (typeHead) return extractTypeName(typeHead, source);
169211
return null;
170212
}
171213

172214
if (node.type === 'module_definition') {
173-
const nameNode = node.childForFieldName('name');
215+
const nameNode = node.childForFieldName('name') ?? node.namedChild(0);
174216
if (nameNode) return getNodeText(nameNode, source);
175217
return null;
176218
}
@@ -203,15 +245,131 @@ export const juliaExtractor: LanguageExtractor = {
203245
return null;
204246
},
205247

248+
/**
249+
* Custom visitor to handle:
250+
* 1. Short-form function definitions: `add(x, y) = x + y`
251+
* 2. `include("file.jl")` as relative file import
252+
* 3. Struct field declarations inside struct bodies
253+
* 4. `module_definition` as a namespace node
254+
*/
255+
visitNode: (node, ctx) => {
256+
const source = ctx.source;
257+
258+
// ── Struct fields ──────────────────────────────────────────────────────────
259+
// Extract typed and untyped fields from struct bodies.
260+
// `typed_expression` (x::Float64) and bare `identifier` (label) as direct
261+
// children of a struct block; `assignment` handles default values (@with_kw).
262+
if (
263+
(node.type === 'typed_expression' || node.type === 'identifier' || node.type === 'assignment') &&
264+
isStructField(node)
265+
) {
266+
const fieldName = extractFieldName(node);
267+
if (fieldName) {
268+
const fieldType = extractFieldType(node, source);
269+
const sig = fieldType ? `${fieldName}::${fieldType}` : fieldName;
270+
ctx.createNode('field', fieldName, node, { signature: sig });
271+
}
272+
return true;
273+
}
274+
275+
// ── Short-form function definitions ────────────────────────────────────────
276+
// `add(x, y) = x + y` → LHS is call_expression
277+
// `f(x::T) where T = x` → LHS is where_expression wrapping call_expression
278+
if (node.type === 'assignment') {
279+
const lhs = node.namedChild(0);
280+
281+
// Unwrap where_expression: `f(x::T) where T = ...`
282+
let callExpr = lhs;
283+
let whereClause = '';
284+
if (callExpr?.type === 'where_expression') {
285+
const whereType = callExpr.namedChild(1);
286+
if (whereType) whereClause = ' where ' + getNodeText(whereType, source);
287+
callExpr = callExpr.namedChild(0) ?? null;
288+
}
289+
290+
if (callExpr?.type === 'call_expression') {
291+
const nameNode = callExpr.namedChild(0);
292+
const funcName = nameNode ? getNodeText(nameNode, source) : null;
293+
if (!funcName) return false; // malformed — let default dispatch walk children
294+
const argsNode = callExpr.namedChild(1);
295+
const sig = argsNode ? getNodeText(argsNode, source) + whereClause : undefined;
296+
ctx.createNode('function', funcName, node, { signature: sig });
297+
// Visit RHS for calls
298+
const rhs = node.namedChild(node.namedChildCount - 1);
299+
if (rhs && rhs !== lhs) ctx.visitNode(rhs);
300+
return true;
301+
}
302+
// Plain assignment at top level (x = 42) — not extracted, but don't re-dispatch
303+
// its children as function/import/call candidates (they'll be visited anyway via
304+
// the default child-walk below returning false).
305+
return false;
306+
}
307+
308+
// ── include("file.jl") as relative file import ─────────────────────────────
309+
// Julia uses include() for relative file composition, not import/using.
310+
if (node.type === 'call_expression') {
311+
const callee = node.namedChild(0);
312+
if (callee?.type === 'identifier' && callee.text === 'include') {
313+
const args = node.namedChild(1);
314+
const strLit = args?.namedChildren.find((n) => n.type === 'string_literal');
315+
const content = strLit?.namedChildren.find((n) => n.type === 'content');
316+
const filePath = content?.text?.trim();
317+
if (filePath && filePath.length < 512 && !filePath.includes('\0')) {
318+
// Use the basename without extension as the module name (matches how
319+
// the file will be indexed). Emit an `imports` reference so the resolver
320+
// can wire up cross-file edges via suffix matching.
321+
const baseName = filePath.replace(/\.jl$/i, '').replace(/.*[\\/]/, '');
322+
ctx.createNode('import', baseName, node, {
323+
signature: `include("${filePath}")`,
324+
});
325+
const parentId = ctx.nodeStack.length > 0
326+
? ctx.nodeStack[ctx.nodeStack.length - 1]
327+
: undefined;
328+
if (parentId) {
329+
ctx.addUnresolvedReference({
330+
fromNodeId: parentId,
331+
referenceName: baseName,
332+
referenceKind: 'imports',
333+
line: node.startPosition.row + 1,
334+
column: node.startPosition.column,
335+
});
336+
}
337+
return true;
338+
}
339+
}
340+
// Not include() — fall through to default call extraction
341+
return false;
342+
}
343+
344+
// ── module_definition as namespace ─────────────────────────────────────────
345+
// Extract `module Foo ... end` as a 'module' kind (maps to NodeKind 'namespace').
346+
if (node.type === 'module_definition') {
347+
const nameNode = node.childForFieldName('name') ?? node.namedChild(0);
348+
if (!nameNode) return false;
349+
const modName = getNodeText(nameNode, source);
350+
const modNode = ctx.createNode('namespace', modName, node, {});
351+
if (modNode) {
352+
ctx.pushScope(modNode.id);
353+
// Visit all children inside the module body
354+
for (let i = 0; i < node.namedChildCount; i++) {
355+
const child = node.namedChild(i);
356+
if (child && child !== nameNode) ctx.visitNode(child);
357+
}
358+
ctx.popScope();
359+
}
360+
return true;
361+
}
362+
363+
return false;
364+
},
365+
206366
extractImport: (node, source) => {
207367
const importText = source.substring(node.startIndex, node.endIndex).trim();
208368

209-
// Extract the module name from `import Foo` / `import Foo.Bar` / `using Foo`
210-
// The first named child is typically an identifier or import_path or selected_import
211369
const firstChild = node.namedChild(0);
212370
if (!firstChild) return { moduleName: importText, signature: importText };
213371

214-
// selected_import: `using Foo: bar, baz` → module is `Foo`
372+
// selected_import: `using Foo: bar, baz` or `import Foo: bar` → module is `Foo`
215373
if (firstChild.type === 'selected_import') {
216374
const pathNode = firstChild.namedChild(0);
217375
if (pathNode) {

0 commit comments

Comments
 (0)