Skip to content

Commit 0624b00

Browse files
committed
feat(ast): implement Issue #41 P0 parser adaptations
- TypeScript: populate TopLevel structure alongside legacy default node - TypeScript: add structured call chain parsing (ReceiverExpr, Chain, ChainArguments, IsOptional) - Go: populate TopLevel structure and add ReceiverExpr to CodeCall - Rust: populate TopLevel structure and add ReceiverExpr to CodeCall - Add regression tests for new structured fields
1 parent 960cf73 commit 0624b00

6 files changed

Lines changed: 254 additions & 28 deletions

File tree

chapi-ast-go/src/main/kotlin/chapi/ast/goast/GoFullIdentListener.kt

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,10 @@ class GoFullIdentListener(var fileName: String) : GoAstListener() {
454454
*/
455455
private fun codeCallFromExprListWithPath(child: ParseTree, arguments: GoParser.ArgumentsContext, functionName: String, targetPath: String): List<CodeCall> {
456456
val calls = mutableListOf<CodeCall>()
457-
458-
val currentCall = CodeCall(NodeName = targetPath).apply {
459-
Parameters = parseArguments(arguments)
460-
FunctionName = functionName
461-
}
457+
458+
// Parse receiver expression and resolve type
459+
var receiverExpr = targetPath
460+
var resolvedNodeName = targetPath
462461

463462
// Resolve local variables and receivers in the target path
464463
if (targetPath.isNotEmpty()) {
@@ -475,18 +474,23 @@ class GoFullIdentListener(var fileName: String) : GoAstListener() {
475474
if (resolvedFirst != null) {
476475
// Replace first part with its resolved type
477476
val remainingParts = parts.drop(1)
478-
val resolvedPath = if (remainingParts.isNotEmpty()) {
477+
resolvedNodeName = if (remainingParts.isNotEmpty()) {
479478
"$resolvedFirst.${remainingParts.joinToString(".")}"
480479
} else {
481480
resolvedFirst
482481
}
483-
currentCall.NodeName = resolvedPath
484-
currentCall.Package = wrapTarget(resolvedPath)
485-
} else {
486-
currentCall.Package = wrapTarget(targetPath)
487482
}
488483
}
489484

485+
val currentCall = CodeCall(
486+
NodeName = resolvedNodeName,
487+
FunctionName = functionName,
488+
Parameters = parseArguments(arguments),
489+
Package = wrapTarget(resolvedNodeName),
490+
// New structured fields (Issue #41)
491+
ReceiverExpr = receiverExpr
492+
)
493+
490494
calls.add(currentCall)
491495
return calls
492496
}
@@ -776,7 +780,14 @@ class GoFullIdentListener(var fileName: String) : GoAstListener() {
776780
codeContainer.DataStructures += entry.value
777781
}
778782

779-
if (defaultNode.Functions.isNotEmpty()) {
783+
// New: populate TopLevel structure (Issue #41 - P0 adaptation)
784+
if (defaultNode.Functions.isNotEmpty() || defaultNode.Fields.isNotEmpty()) {
785+
codeContainer.TopLevel = TopLevelScope(
786+
Functions = defaultNode.Functions,
787+
Fields = defaultNode.Fields
788+
)
789+
790+
// Legacy: also maintain default node for backward compatibility
780791
codeContainer.DataStructures += defaultNode
781792
}
782793

chapi-ast-go/src/test/kotlin/chapi/ast/goast/GoAnalyserTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ func main() {
3030
CodeCall(
3131
NodeName = "fmt",
3232
FunctionName = "Println",
33-
Parameters = listOf(CodeProperty(TypeValue = "hello world", TypeType = "string"))
33+
Parameters = listOf(CodeProperty(TypeValue = "hello world", TypeType = "string")),
34+
// New structured field (Issue #41)
35+
ReceiverExpr = "fmt"
3436
)
3537
),
3638
)

chapi-ast-rust/src/main/kotlin/chapi/ast/rustast/RustAstBaseListener.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ open class RustAstBaseListener(private val fileName: String) : RustParserBaseLis
5858
val allStruct = structMap.values
5959
DataStructures = (buildDedicatedStructs() + allStruct)
6060
Imports = imports
61+
62+
// New: populate TopLevel structure (Issue #41 - P0 adaptation)
63+
if (individualFunctions.isNotEmpty() || individualFields.isNotEmpty()) {
64+
TopLevel = TopLevelScope(
65+
Functions = individualFunctions,
66+
Fields = individualFields
67+
)
68+
}
6169
}
6270

6371
override fun enterModule(ctx: RustParser.ModuleContext?) {

chapi-ast-rust/src/main/kotlin/chapi/ast/rustast/RustFullIdentListener.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ class RustFullIdentListener(fileName: String) : RustAstBaseListener(fileName) {
4949
FunctionName = pureFuncName,
5050
OriginNodeName = nodeName,
5151
Parameters = buildParameters(ctx?.callParams()),
52-
Position = buildPosition(ctx ?: return)
52+
Position = buildPosition(ctx ?: return),
53+
// New structured fields (Issue #41)
54+
ReceiverExpr = nodeName
5355
)
5456
}
5557

@@ -102,7 +104,9 @@ class RustFullIdentListener(fileName: String) : RustAstBaseListener(fileName) {
102104
OriginNodeName = instanceVar.ifEmpty { nodeName },
103105
FunctionName = functionName,
104106
Parameters = buildParameters(ctx?.callParams()),
105-
Position = buildPosition(ctx ?: return)
107+
Position = buildPosition(ctx ?: return),
108+
// New structured fields (Issue #41)
109+
ReceiverExpr = instanceVar.ifEmpty { nodeName }
106110
)
107111
}
108112

chapi-ast-typescript/src/main/kotlin/chapi/ast/typescriptast/TypeScriptFullIdentListener.kt

Lines changed: 138 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -902,9 +902,21 @@ class TypeScriptFullIdentListener(val node: TSIdentify) : TypeScriptAstListener(
902902

903903
// For chained calls like axios(...).then(...).catch(...)
904904
if (callee is TypeScriptParser.MemberDotExpressionContext) {
905-
val rawFn = buildCallChain(callee).ifBlank { callee.text }
905+
val chainInfo = buildStructuredCallChain(callee)
906+
val rawFn = chainInfo.legacyName.ifBlank { callee.text }
906907
val fn = normalizeMemberCallName(rawFn)
907-
currentFunc.FunctionCalls += CodeCall("", CallType.FUNCTION, "", fn, args)
908+
909+
// Build structured CodeCall with new fields
910+
currentFunc.FunctionCalls += CodeCall(
911+
Type = CallType.FUNCTION,
912+
FunctionName = fn,
913+
Parameters = args,
914+
// New structured fields (Issue #41)
915+
ReceiverExpr = chainInfo.receiverExpr,
916+
Chain = chainInfo.chain,
917+
ChainArguments = chainInfo.chainArguments,
918+
IsOptional = chainInfo.isOptional
919+
)
908920
return
909921
}
910922

@@ -913,6 +925,93 @@ class TypeScriptFullIdentListener(val node: TSIdentify) : TypeScriptAstListener(
913925
currentFunc.FunctionCalls += CodeCall("", CallType.FUNCTION, "", currentExprIdent, args)
914926
}
915927

928+
/**
929+
* Data class holding structured chain call information.
930+
*/
931+
private data class ChainCallInfo(
932+
val receiverExpr: String = "",
933+
val firstMethod: String = "",
934+
val chain: List<String> = listOf(),
935+
val chainArguments: List<List<CodeProperty>> = listOf(),
936+
val isOptional: Boolean = false,
937+
val legacyName: String = "" // For backward compatibility
938+
)
939+
940+
/**
941+
* Builds structured chain call info from MemberDotExpression.
942+
* Returns ChainCallInfo with receiver, chain methods, and their arguments.
943+
*/
944+
private fun buildStructuredCallChain(expr: TypeScriptParser.MemberDotExpressionContext): ChainCallInfo {
945+
val chainMethods = mutableListOf<String>()
946+
val chainArgs = mutableListOf<List<CodeProperty>>()
947+
var receiver = ""
948+
var isOptional = false
949+
950+
// Walk down the chain to collect all method names and arguments
951+
fun walkChain(e: TypeScriptParser.SingleExpressionContext?) {
952+
when (e) {
953+
is TypeScriptParser.MemberDotExpressionContext -> {
954+
// Add current method name to the front
955+
chainMethods.add(0, e.identifierName().text)
956+
957+
// Check for optional chaining (?.method)
958+
if (e.QuestionMark() != null) {
959+
isOptional = true
960+
}
961+
962+
// Continue walking down
963+
walkChain(e.singleExpression())
964+
}
965+
is TypeScriptParser.ArgumentsExpressionContext -> {
966+
// Collect arguments for this call
967+
val args = buildArguments(e.arguments())
968+
chainArgs.add(0, args)
969+
walkChain(e.singleExpression())
970+
}
971+
is TypeScriptParser.GenericCallExpressionContext -> {
972+
receiver = e.identifierName().text
973+
}
974+
is IdentifierExpressionContext -> {
975+
receiver = e.identifierName().text
976+
}
977+
is TypeScriptParser.OptionalCallExpressionContext -> {
978+
isOptional = true
979+
walkChain(e.singleExpression())
980+
}
981+
else -> {
982+
// Try to extract identifier from raw text
983+
val raw = e?.text ?: ""
984+
val match = Regex("^[A-Za-z_$][A-Za-z0-9_$]*").find(raw)?.value
985+
if (!match.isNullOrBlank()) {
986+
receiver = match
987+
}
988+
}
989+
}
990+
}
991+
992+
walkChain(expr)
993+
994+
// First method goes to FunctionName, rest go to Chain
995+
val firstMethod = chainMethods.firstOrNull() ?: ""
996+
val restChain = if (chainMethods.size > 1) chainMethods.drop(1) else listOf()
997+
998+
// Build legacy name for backward compatibility
999+
val legacyName = if (receiver.isNotBlank()) {
1000+
"$receiver->${chainMethods.joinToString("->")}"
1001+
} else {
1002+
chainMethods.joinToString("->")
1003+
}
1004+
1005+
return ChainCallInfo(
1006+
receiverExpr = receiver,
1007+
firstMethod = firstMethod,
1008+
chain = restChain,
1009+
chainArguments = if (chainArgs.size > 1) chainArgs.drop(1) else listOf(),
1010+
isOptional = isOptional,
1011+
legacyName = legacyName
1012+
)
1013+
}
1014+
9161015
private fun normalizeMemberCallName(name: String): String {
9171016
// Keep legacy "axios.get" style for simple member calls,
9181017
// but keep "axios->then"/"request->...->catch" style for promise chains.
@@ -1128,11 +1227,28 @@ class TypeScriptFullIdentListener(val node: TSIdentify) : TypeScriptAstListener(
11281227
for (singleExprCtx in ctx.expressionSequence().singleExpression()) {
11291228
when (singleExprCtx) {
11301229
is TypeScriptParser.ArgumentsExpressionContext -> {
1230+
val nodeName = wrapTargetType(singleExprCtx)
1231+
val funcName = buildFunctionName(singleExprCtx)
1232+
1233+
// Check if this is a chained call (contains ->)
1234+
val callee = singleExprCtx.singleExpression()
1235+
val (receiverExpr, chain, chainArgs, isOptional) = if (callee is TypeScriptParser.MemberDotExpressionContext) {
1236+
val info = buildStructuredCallChain(callee)
1237+
Quadruple(info.receiverExpr, info.chain, info.chainArguments, info.isOptional)
1238+
} else {
1239+
Quadruple(nodeName, listOf<String>(), listOf<List<CodeProperty>>(), false)
1240+
}
1241+
11311242
currentFunc.FunctionCalls += CodeCall(
11321243
Parameters = processArgumentList(singleExprCtx.arguments()?.argumentList()),
1133-
FunctionName = buildFunctionName(singleExprCtx),
1134-
NodeName = wrapTargetType(singleExprCtx),
1135-
Position = buildPosition(ctx)
1244+
FunctionName = funcName,
1245+
NodeName = nodeName,
1246+
Position = buildPosition(ctx),
1247+
// New structured fields (Issue #41)
1248+
ReceiverExpr = receiverExpr,
1249+
Chain = chain,
1250+
ChainArguments = chainArgs,
1251+
IsOptional = isOptional
11361252
)
11371253
}
11381254

@@ -1156,6 +1272,9 @@ class TypeScriptFullIdentListener(val node: TSIdentify) : TypeScriptAstListener(
11561272

11571273
}
11581274
}
1275+
1276+
/** Simple quadruple data class for destructuring. */
1277+
private data class Quadruple<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
11591278

11601279
private fun buildFunctionName(argsCtx: TypeScriptParser.ArgumentsExpressionContext): String {
11611280
val name = functionNameFromArguments(argsCtx)
@@ -1404,16 +1523,21 @@ class TypeScriptFullIdentListener(val node: TSIdentify) : TypeScriptAstListener(
14041523
}
14051524

14061525
// for: `export const baseURL = '/api'`
1407-
val fieldOnly = defaultNode.Fields.isNotEmpty()
1526+
val hasFields = defaultNode.Fields.isNotEmpty()
14081527
// for export default function
1409-
val functionOnly = defaultNode.Functions.isNotEmpty()
1410-
1411-
if (functionOnly) {
1412-
defaultNode.NodeName = "default"
1413-
defaultNode.FilePath = codeContainer.FullName
1414-
defaultNode.Package = codeContainer.PackageName
1415-
codeContainer.DataStructures += defaultNode
1416-
} else if (fieldOnly) {
1528+
val hasFunctions = defaultNode.Functions.isNotEmpty()
1529+
// for exports
1530+
val hasExports = defaultNode.Exports.isNotEmpty()
1531+
1532+
// New: populate TopLevel structure (Issue #41 - P0 adaptation)
1533+
if (hasFunctions || hasFields || hasExports) {
1534+
codeContainer.TopLevel = TopLevelScope(
1535+
Functions = defaultNode.Functions,
1536+
Fields = defaultNode.Fields,
1537+
Exports = defaultNode.Exports
1538+
)
1539+
1540+
// Legacy: also maintain "default" node for backward compatibility
14171541
defaultNode.NodeName = "default"
14181542
defaultNode.FilePath = codeContainer.FullName
14191543
defaultNode.Package = codeContainer.PackageName

chapi-ast-typescript/src/test/kotlin/chapi/ast/typescriptast/TypeScriptCallTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import kotlinx.serialization.encodeToString
44
import kotlinx.serialization.json.Json
55
import org.junit.jupiter.api.Test
66
import kotlin.test.assertEquals
7+
import kotlin.test.assertNotNull
8+
import kotlin.test.assertTrue
79

810
internal class TypeScriptCallTest {
911

@@ -24,4 +26,79 @@ function testNew() {
2426
assertEquals(functionCalls[0].NodeName, "Employee")
2527
assertEquals(functionCalls[0].Parameters.size, 0)
2628
}
29+
30+
/**
31+
* Issue #41 - Test TopLevel structure is populated for TypeScript
32+
*/
33+
@Test
34+
fun shouldPopulateTopLevelStructure() {
35+
val code = """
36+
const API_URL = "https://api.example.com";
37+
38+
export function fetchData() {
39+
return fetch(API_URL);
40+
}
41+
42+
export const handler = async () => {
43+
return "result";
44+
};
45+
"""
46+
val codeFile = TypeScriptAnalyser().analysis(code, "test.ts")
47+
48+
// New: TopLevel should be populated
49+
assertNotNull(codeFile.TopLevel, "TopLevel should be populated")
50+
assertTrue(codeFile.TopLevel!!.Functions.isNotEmpty(), "TopLevel should have functions")
51+
assertTrue(codeFile.TopLevel!!.Fields.isNotEmpty(), "TopLevel should have fields")
52+
53+
// Should have fetchData and handler functions
54+
val functionNames = codeFile.TopLevel!!.Functions.map { it.Name }
55+
assertTrue(functionNames.contains("fetchData"), "TopLevel should contain fetchData function")
56+
assertTrue(functionNames.contains("handler"), "TopLevel should contain handler function")
57+
58+
// Should have API_URL field
59+
val fieldNames = codeFile.TopLevel!!.Fields.map { it.TypeKey }
60+
assertTrue(fieldNames.contains("API_URL"), "TopLevel should contain API_URL field")
61+
}
62+
63+
/**
64+
* Issue #41 - Test structured import/export with new fields
65+
*/
66+
@Test
67+
fun shouldPopulateStructuredImportFields() {
68+
val code = """
69+
import { foo, bar as baz } from "module";
70+
import * as utils from "./utils";
71+
import React from "react";
72+
"""
73+
val codeFile = TypeScriptAnalyser().analysis(code, "test.ts")
74+
75+
val namedImport = codeFile.Imports.find { it.Source == "module" }
76+
assertNotNull(namedImport)
77+
assertEquals(chapi.domain.core.ImportKind.NAMED, namedImport.Kind)
78+
assertTrue(namedImport.Specifiers.isNotEmpty(), "Named import should have specifiers")
79+
80+
val namespaceImport = codeFile.Imports.find { it.Source.contains("utils") }
81+
assertNotNull(namespaceImport)
82+
assertEquals(chapi.domain.core.ImportKind.NAMESPACE, namespaceImport.Kind)
83+
assertEquals("utils", namespaceImport.NamespaceName)
84+
85+
val defaultImport = codeFile.Imports.find { it.Source == "react" }
86+
assertNotNull(defaultImport)
87+
assertEquals(chapi.domain.core.ImportKind.DEFAULT, defaultImport.Kind)
88+
assertEquals("React", defaultImport.DefaultName)
89+
}
90+
91+
/**
92+
* Issue #41 - Test CodeContainer new fields (Language, Kind)
93+
*/
94+
@Test
95+
fun shouldPopulateContainerMetadata() {
96+
val code = """
97+
export const x = 1;
98+
"""
99+
val codeFile = TypeScriptAnalyser().analysis(code, "test.ts")
100+
101+
assertEquals("typescript", codeFile.Language)
102+
assertEquals(chapi.domain.core.ContainerKind.MODULE, codeFile.Kind)
103+
}
27104
}

0 commit comments

Comments
 (0)