diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/julia.ts b/gitnexus/src/core/ingestion/field-extractors/configs/julia.ts index ecf6b0e9e..fabf0c92f 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/julia.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/julia.ts @@ -1,47 +1,71 @@ // gitnexus/src/core/ingestion/field-extractors/configs/julia.ts -// Verified against tree-sitter-julia grammar import { SupportedLanguages } from 'gitnexus-shared'; import type { FieldExtractionConfig } from '../generic.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; /** - * Julia field extraction config. + * Extract the struct name from a struct_definition node. * - * Julia struct fields appear as typed or untyped identifiers inside struct bodies: - * struct Point - * x::Float64 - * y::Float64 - * end - * - * In tree-sitter-julia the struct body contains field declarations. - * Each field is either an identifier or a typed_parameter node. + * Julia grammar has no named fields on struct_definition, so + * childForFieldName('name') always returns null. The name lives at: + * struct_definition > type_head > identifier (simple) + * struct_definition > type_head > binary_expression > identifier (with <: supertype) */ +function extractStructOwnerName(node: SyntaxNode): string | undefined { + const typeHead = node.namedChild(0); + if (typeHead?.type !== 'type_head') return undefined; + const first = typeHead.firstNamedChild; + if (first?.type === 'identifier') return first.text; + // struct Foo <: Bar → binary_expression first child is the name identifier + if (first?.type === 'binary_expression') return first.firstNamedChild?.text; + return undefined; +} + +/** + * Extract field name + type from a struct field node. + * + * Handles three shapes that appear as direct children of struct_definition: + * identifier → plain untyped field: `label` + * typed_expression → typed field: `x::Float64` + * assignment → field with default (@with_kw): `x::Int = 1` or `flag = false` + * + * The `type_head` child (struct name / supertype) is excluded via fieldNodeTypes. + */ +function extractFieldName(node: SyntaxNode): string | undefined { + if (node.type === 'identifier') return node.text; + if (node.type === 'typed_expression') return node.firstNamedChild?.text; + if (node.type === 'assignment') { + const lhs = node.firstNamedChild; + if (lhs?.type === 'typed_expression') return lhs.firstNamedChild?.text; + if (lhs?.type === 'identifier') return lhs.text; + } + return undefined; +} + +function extractFieldType(node: SyntaxNode): string | undefined { + if (node.type === 'typed_expression') return node.namedChild(1)?.text?.trim(); + if (node.type === 'assignment') { + const lhs = node.firstNamedChild; + if (lhs?.type === 'typed_expression') return lhs.namedChild(1)?.text?.trim(); + } + return undefined; +} + export const juliaConfig: FieldExtractionConfig = { language: SupportedLanguages.Julia, typeDeclarationNodes: ['struct_definition'], - fieldNodeTypes: ['identifier', 'typed_parameter'], - bodyNodeTypes: ['field_declaration_list'], + // Fields are direct children of struct_definition (no wrapper body node). + // type_head holds the struct name and is excluded by listing only field shapes. + fieldNodeTypes: ['identifier', 'typed_expression', 'assignment'], + bodyNodeTypes: [], + useOwnerAsBody: true, + extractOwnerName: extractStructOwnerName, defaultVisibility: 'public', - - extractName(node) { - if (node.type === 'identifier') return node.text; - if (node.type === 'typed_parameter') { - const nameNode = node.childForFieldName('name') ?? node.firstNamedChild; - return nameNode?.type === 'identifier' ? nameNode.text : undefined; - } - return undefined; - }, - - extractType(node) { - if (node.type === 'typed_parameter') { - const typeNode = node.childForFieldName('type') ?? node.namedChild(1); - return typeNode?.text?.trim(); - } - return undefined; - }, + extractName: extractFieldName, + extractType: extractFieldType, extractVisibility(_node) { - // Julia struct fields are always public (no access modifiers) return 'public'; }, @@ -50,9 +74,6 @@ export const juliaConfig: FieldExtractionConfig = { }, isReadonly(_node) { - // Immutable structs (struct, not mutable struct) have readonly fields. - // The mutable keyword is on the parent struct_definition, not on individual fields. - // We return false here; the struct-level immutability is handled elsewhere. return false; }, }; diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index 4cb4a5b1b..960095432 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -56,6 +56,18 @@ export interface FieldExtractionConfig { /** Extract fields from primary constructor parameters on the owner node itself * (e.g. C# record positional parameters, C# 12 class primary constructors). */ extractPrimaryFields?: (ownerNode: SyntaxNode, context: FieldExtractorContext) => FieldInfo[]; + /** + * Custom owner name extractor for grammars where `childForFieldName('name')` does not work + * (e.g. Julia, where struct_definition has no named grammar fields). + * When provided, used instead of `node.childForFieldName('name')?.text`. + */ + extractOwnerName?: (node: SyntaxNode) => string | undefined; + /** + * When true, use the owner node itself as the field body (fields are direct children). + * For grammars like Julia where struct fields live directly under the type-declaration node + * rather than inside a separate body wrapper node. + */ + useOwnerAsBody?: boolean; } // --------------------------------------------------------------------------- @@ -85,9 +97,8 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac if (!this.isTypeDeclaration(node)) return null; const nameNode = node.childForFieldName('name'); - if (!nameNode) return null; - - const ownerFqn = nameNode.text; + const ownerFqn = config.extractOwnerName?.(node) ?? nameNode?.text; + if (!ownerFqn) return null; const fields: FieldInfo[] = []; // Find body container(s) @@ -110,6 +121,7 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac // ------------------------------------------------------------------ private findBodies(node: SyntaxNode): SyntaxNode[] { + if (config.useOwnerAsBody) return [node]; const result: SyntaxNode[] = []; // Try named 'body' field first const bodyField = node.childForFieldName('body'); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts b/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts index f548f509e..78059416b 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/julia.ts @@ -52,7 +52,8 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] { }); break; } - case 'typed_parameter': { + case 'typed_parameter': + case 'typed_expression': { const nameNode = param.childForFieldName('name') ?? param.firstNamedChild; const typeNode = param.childForFieldName('type') ?? param.namedChild(1); if (nameNode) { @@ -66,6 +67,33 @@ function extractJuliaParameters(node: SyntaxNode): ParameterInfo[] { } break; } + case 'named_argument': { + // Default positional (x::Int=1) or keyword (; kw::String="hello") parameter. + // First named child is typed_expression (typed) or identifier (untyped). + const first = param.firstNamedChild; + if (first?.type === 'typed_expression') { + const nameNode = first.firstNamedChild; + const typeNode = first.namedChild(1); + if (nameNode) { + params.push({ + name: nameNode.text, + type: typeNode?.text?.trim() ?? null, + rawType: typeNode?.text?.trim() ?? null, + isOptional: true, + isVariadic: false, + }); + } + } else if (first?.type === 'identifier') { + params.push({ + name: first.text, + type: null, + rawType: null, + isOptional: true, + isVariadic: false, + }); + } + break; + } case 'optional_parameter': { const nameNode = param.childForFieldName('name') ?? param.firstNamedChild; const typeNode = param.childForFieldName('type'); diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts index 2a08df8b6..948e55955 100644 --- a/gitnexus/src/core/ingestion/utils/method-props.ts +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -151,9 +151,7 @@ export function buildMethodProps(info: MethodInfo): Record { } return { parameterCount: hasVariadic ? undefined : info.parameters.length, - ...(!hasVariadic && optionalCount > 0 - ? { requiredParameterCount: info.parameters.length - optionalCount } - : {}), + ...(!hasVariadic ? { requiredParameterCount: info.parameters.length - optionalCount } : {}), ...(types.length > 0 ? { parameterTypes: types } : {}), returnType: info.returnType ?? undefined, visibility: info.visibility, diff --git a/gitnexus/test/fixtures/lang-resolution/julia-with-kw/app.jl b/gitnexus/test/fixtures/lang-resolution/julia-with-kw/app.jl new file mode 100644 index 000000000..bda65f2e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/julia-with-kw/app.jl @@ -0,0 +1,21 @@ +using Parameters + +@with_kw struct ModelConfig + hidden_size::Int = 256 + dropout::Float64 = 0.1 + label::String = "default" + verbose = false +end + +struct Point + x::Float64 + y::Float64 +end + +function train(cfg::ModelConfig; epochs::Int=10, lr::Float64=0.001) + return cfg.hidden_size +end + +function distance(a::Point, b::Point) + return a.x - b.x +end diff --git a/gitnexus/test/integration/resolvers/julia.test.ts b/gitnexus/test/integration/resolvers/julia.test.ts index 6154da83a..d30dc8645 100644 --- a/gitnexus/test/integration/resolvers/julia.test.ts +++ b/gitnexus/test/integration/resolvers/julia.test.ts @@ -173,3 +173,43 @@ describe('Julia export visibility in top-level scripts', () => { expect(api?.properties.isExported).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Parameters.jl @with_kw — struct field extraction + keyword/default params +// --------------------------------------------------------------------------- + +describe('Julia @with_kw struct and keyword parameter extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'julia-with-kw'), () => {}); + }, 60000); + + it('detects @with_kw struct as a Class node', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('ModelConfig'); + }); + + it('detects plain struct as a Class node', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Point'); + }); + + it('extracts keyword/default parameters for train(cfg; epochs, lr)', () => { + const fns = getNodesByLabelFull(result, 'Function'); + const train = fns.find((n) => n.name === 'train'); + expect(train).toBeDefined(); + expect(train?.properties.parameterCount).toBe(3); + expect(train?.properties.requiredParameterCount).toBe(1); + expect(train?.properties.parameterTypes).toEqual(['ModelConfig', 'Int', 'Float64']); + }); + + it('extracts required-only parameters for distance(a, b)', () => { + const fns = getNodesByLabelFull(result, 'Function'); + const dist = fns.find((n) => n.name === 'distance'); + expect(dist).toBeDefined(); + expect(dist?.properties.parameterCount).toBe(2); + expect(dist?.properties.requiredParameterCount).toBe(2); + expect(dist?.properties.parameterTypes).toEqual(['Point', 'Point']); + }); +});