1699 lines
78 KiB
Java
1699 lines
78 KiB
Java
package com.agenticcode.parsernatural;
|
|
|
|
import com.agenticcode.parsercore.ast.model.AstEdge;
|
|
import com.agenticcode.parsercore.ast.model.AstNode;
|
|
import com.agenticcode.parsercore.ast.model.EdgeType;
|
|
import com.agenticcode.parsercore.ast.model.NodeType;
|
|
import com.agenticcode.parsercore.ast.spi.LanguageParser;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
class NaturalParserTest {
|
|
|
|
private final NaturalParser parser = new NaturalParser();
|
|
|
|
private static AstNode findNode(LanguageParser.ParseResult result, NodeType type, String name) {
|
|
return result.nodes().stream()
|
|
.filter(n -> n.type() == type && n.name().equals(name))
|
|
.findFirst()
|
|
.orElseThrow(() -> new AssertionError("No node " + type + " " + name));
|
|
}
|
|
|
|
private static boolean hasNode(LanguageParser.ParseResult result, NodeType type, String name) {
|
|
return result.nodes().stream().anyMatch(n -> n.type() == type && n.name().equals(name));
|
|
}
|
|
|
|
/**
|
|
* The ascending line numbers of {@code type} edges from {@code source} to the DB_TABLE {@code table}.
|
|
*/
|
|
private static List<Integer> edgeLines(LanguageParser.ParseResult result, EdgeType type, AstNode source, String table) {
|
|
return result.edges().stream()
|
|
.filter(e -> e.type() == type && e.sourceId().equals(source.id()))
|
|
.filter(e -> result.nodes().stream().anyMatch(
|
|
n -> n.id().equals(e.targetId()) && n.type() == NodeType.DB_TABLE && n.name().equals(table)))
|
|
.map(AstEdge::lineNo)
|
|
.distinct()
|
|
.sorted()
|
|
.toList();
|
|
}
|
|
|
|
private static List<Integer> readLines(LanguageParser.ParseResult result, AstNode source, String table) {
|
|
return edgeLines(result, EdgeType.READS, source, table);
|
|
}
|
|
|
|
private static List<Integer> writeLines(LanguageParser.ParseResult result, AstNode source, String table) {
|
|
return edgeLines(result, EdgeType.WRITES, source, table);
|
|
}
|
|
|
|
private static boolean hasEdge(LanguageParser.ParseResult result, EdgeType type, AstNode source, String targetName, NodeType targetType) {
|
|
return result.edges().stream().anyMatch(e -> e.type() == type
|
|
&& e.sourceId().equals(source.id())
|
|
&& result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId()) && n.name().equals(targetName) && n.type() == targetType));
|
|
}
|
|
|
|
private static String readFixture(String name) throws IOException {
|
|
Path path = Path.of("src/test/resources/fixtures/natural", name);
|
|
return Files.readString(path, StandardCharsets.UTF_8);
|
|
}
|
|
|
|
@Test
|
|
void language() {
|
|
assertEquals("natural", parser.language());
|
|
}
|
|
|
|
@Test
|
|
void capturesModuleDescriptionFromBanner() {
|
|
String content = """
|
|
**SAG GENERATOR: VERSIS-XML-IF
|
|
**SAG TITLE: XML Interface for BGEAGFN
|
|
* Title : ignored once SAG TITLE is found
|
|
DEFINE DATA LOCAL
|
|
01 #X (A1)
|
|
END-DEFINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("WGEAGB0S.nat", content);
|
|
|
|
AstNode module = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.MODULE)
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(module.properties());
|
|
assertEquals("XML Interface for BGEAGFN", module.properties().get("description"));
|
|
}
|
|
|
|
@Test
|
|
void fallsBackToTitleHeaderWhenNoSagBanner() {
|
|
String content = """
|
|
* Title : XML/web maintenance interface
|
|
* Function : does stuff
|
|
DEFINE DATA LOCAL
|
|
01 #X (A1)
|
|
END-DEFINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("W-MNT-N0.nat", content);
|
|
|
|
AstNode module = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.MODULE)
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(module.properties());
|
|
assertEquals("XML/web maintenance interface", module.properties().get("description"));
|
|
}
|
|
|
|
@Test
|
|
void parsesSampleModule() throws IOException {
|
|
String content = readFixture("YADDRBN0_SAMPLE.nat");
|
|
|
|
LanguageParser.ParseResult result = parser.parse("YADDRBN0_SAMPLE.nat", content);
|
|
|
|
AstNode module = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.MODULE)
|
|
.findFirst().orElseThrow();
|
|
assertEquals("YADDRBN0_SAMPLE", module.name());
|
|
|
|
// includes (PARAMETER USING / LOCAL USING)
|
|
assertTrue(hasNode(result, NodeType.DATA_STRUCTURE, "YADDRKEY"));
|
|
assertTrue(hasNode(result, NodeType.DATA_STRUCTURE, "CDRANGEA"));
|
|
assertTrue(hasEdge(result, EdgeType.INCLUDES, module, "YADDRKEY", NodeType.DATA_STRUCTURE));
|
|
|
|
// constants
|
|
AstNode alphaTrue = findNode(result, NodeType.CONSTANT, "#ALPHA-TRUE");
|
|
assertEquals("'1'", alphaTrue.value());
|
|
AstNode maxAttempts = findNode(result, NodeType.CONSTANT, "#MAX-ATTEMPTS");
|
|
assertEquals("3", maxAttempts.value());
|
|
|
|
// REDEFINE group with nested fields
|
|
AstNode redefineGroup = findNode(result, NodeType.DATA_STRUCTURE, "#S_ADDRESS-SP");
|
|
AstNode grp = findNode(result, NodeType.DATA_STRUCTURE, "#S_ADDRESS-SP-GRP");
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, redefineGroup, "#S_ADDRESS-SP-GRP", NodeType.DATA_STRUCTURE));
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, grp, "COD-EMISOR", NodeType.VARIABLE));
|
|
|
|
// subroutines
|
|
AstNode initializations = findNode(result, NodeType.FUNCTION, "INITIALIZATIONS");
|
|
AstNode rAddressSp = findNode(result, NodeType.FUNCTION, "R-ADDRESS_SP");
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "INITIALIZATIONS", NodeType.FUNCTION));
|
|
|
|
// PERFORM (internal call) and CALLNAT (external call)
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "INITIALIZATIONS", NodeType.FUNCTION));
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "CDRANGE", NodeType.MODULE));
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, initializations, "R-ADDRESS_SP", NodeType.FUNCTION));
|
|
|
|
// SQL SELECT ... FROM <view> and UPDATE <view>
|
|
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, "VERSVW_ADDRESS", NodeType.DB_TABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, rAddressSp, "VERSVW_ADDRESS", NodeType.DB_TABLE));
|
|
|
|
// DB_ACCESS nodes for SELECT and UPDATE, with raw statement text and CONTAINS/USES_TYPE edges
|
|
AstNode selectAccess = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.DB_ACCESS && "SELECT".equals(n.dataType()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No SELECT DB_ACCESS node"));
|
|
assertTrue(selectAccess.value() != null && selectAccess.value().contains("SELECT *"));
|
|
assertTrue(selectAccess.value() != null && selectAccess.value().contains("END-SELECT"));
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, rAddressSp, selectAccess.name(), NodeType.DB_ACCESS));
|
|
assertTrue(hasEdge(result, EdgeType.USES_TYPE, selectAccess, "VERSVW_ADDRESS", NodeType.DB_TABLE));
|
|
assertTrue(hasEdge(result, EdgeType.USES_TYPE, selectAccess, "VDB2-VERSIS_ADDRESS", NodeType.DATA_STRUCTURE));
|
|
|
|
AstNode updateAccess = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.DB_ACCESS && "WRITE".equals(n.dataType()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No WRITE DB_ACCESS node"));
|
|
assertEquals("UPDATE VERSVW_ADDRESS", updateAccess.value());
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, rAddressSp, updateAccess.name(), NodeType.DB_ACCESS));
|
|
assertTrue(hasEdge(result, EdgeType.USES_TYPE, updateAccess, "VERSVW_ADDRESS", NodeType.DB_TABLE));
|
|
|
|
// ASSIGN #COUNTER = #COUNTER + 1 -> reads and writes #COUNTER
|
|
AstNode counter = findNode(result, NodeType.VARIABLE, "#COUNTER");
|
|
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, counter.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, rAddressSp, counter.name(), NodeType.VARIABLE));
|
|
|
|
// MOVE #MAX-ATTEMPTS TO #COUNTER -> reads #MAX-ATTEMPTS, writes #COUNTER
|
|
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, maxAttempts.name(), NodeType.CONSTANT));
|
|
|
|
// Nested IF/FOR/IF control flow inside INITIALIZATIONS
|
|
AstNode outerIf = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "IF".equals(n.dataType())
|
|
&& "#COUNTER < #MAX-ATTEMPTS".equals(n.value()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No outer IF block"));
|
|
AstNode forBlock = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "FOR".equals(n.dataType()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No FOR block"));
|
|
AstNode innerIf = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "IF".equals(n.dataType())
|
|
&& "#COUNTER = #MAX-ATTEMPTS".equals(n.value()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No inner IF block"));
|
|
|
|
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
|
|
&& e.sourceId().equals(initializations.id()) && e.targetId().equals(outerIf.id())));
|
|
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
|
|
&& e.sourceId().equals(outerIf.id()) && e.targetId().equals(forBlock.id())));
|
|
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
|
|
&& e.sourceId().equals(forBlock.id()) && e.targetId().equals(innerIf.id())));
|
|
assertEquals("#COUNTER = 1 TO #MAX-ATTEMPTS", forBlock.value());
|
|
assertTrue(outerIf.startLine() < forBlock.startLine());
|
|
assertTrue(forBlock.endLine() < outerIf.endLine());
|
|
}
|
|
|
|
@Test
|
|
void parsesLdaWithMultipleTopLevelStructures() throws IOException {
|
|
String content = readFixture("YFRAML01_SAMPLE.lda");
|
|
|
|
LanguageParser.ParseResult result = parser.parse("YFRAML01_SAMPLE.lda", content);
|
|
|
|
AstNode yframl01 = findNode(result, NodeType.DATA_STRUCTURE, "YFRAML01");
|
|
AstNode debug = findNode(result, NodeType.DATA_STRUCTURE, "YFRAML01-DEBUG");
|
|
|
|
// top-level structures have no CONTAINS parent
|
|
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(yframl01.id())));
|
|
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(debug.id())));
|
|
|
|
// CONST<...> -> CONSTANT with type/length prefix as dataType
|
|
AstNode retSuccess = findNode(result, NodeType.CONSTANT, "C-RET-SUCCESS");
|
|
assertEquals("N4", retSuccess.dataType());
|
|
assertEquals("0", retSuccess.value());
|
|
AstNode wildcard = findNode(result, NodeType.CONSTANT, "C-WILDCARD");
|
|
assertEquals("'*'", wildcard.value());
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, yframl01, "C-RET-SUCCESS", NodeType.CONSTANT));
|
|
|
|
// INIT<...> -> VARIABLE (no CONST value), type-only prefix ("L") as dataType
|
|
AstNode debugErr = findNode(result, NodeType.VARIABLE, "L-DEBUG-ERR");
|
|
assertEquals("L", debugErr.dataType());
|
|
assertNull(debugErr.value());
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, debug, "L-DEBUG-ERR", NodeType.VARIABLE));
|
|
|
|
AstNode debugProg = findNode(result, NodeType.VARIABLE, "L-DEBUG-PROG");
|
|
assertEquals("A8", debugProg.dataType());
|
|
}
|
|
|
|
@Test
|
|
void fieldFormatAttachedWithoutSpaceIsSplitFromName() {
|
|
// A DEFINE DATA field may glue its format to the name with no space, e.g. `01 KEY(A1/1:3,1:V)`
|
|
// or `01 #DELIMITER(A1)`. The name must be KEY / #DELIMITER (not the whole token), with the
|
|
// format captured as dataType — otherwise the field is unsearchable and mistyped as a group.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 KEY(A1/1:3,1:V)
|
|
01 #DELIMITER(A1)
|
|
01 #SPACED (I4)
|
|
END-DEFINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("GLUED.nat", content);
|
|
|
|
AstNode key = findNode(result, NodeType.VARIABLE, "KEY");
|
|
assertEquals("A1/1:3,1:V", key.dataType());
|
|
AstNode delim = findNode(result, NodeType.VARIABLE, "#DELIMITER");
|
|
assertEquals("A1", delim.dataType());
|
|
// The spaced form still works (regression guard).
|
|
AstNode spaced = findNode(result, NodeType.VARIABLE, "#SPACED");
|
|
assertEquals("I4", spaced.dataType());
|
|
// The glued token must NOT survive as a node name.
|
|
assertTrue(result.nodes().stream().noneMatch(n -> "KEY(A1/1:3,1:V)".equals(n.name())),
|
|
"glued name+format must not be stored as a single identifier");
|
|
}
|
|
|
|
@Test
|
|
void decideValueAssignmentsCarryDispatchGuard() {
|
|
// A DECIDE ON VALUE OF <subject> that assigns a program name per branch is a dispatch table:
|
|
// each WRITES edge must carry the controlling guard (whenField/whenValue) so the
|
|
// object-type → program mapping is recoverable (P1-z).
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #P-OBJECT-TYPE (A32)
|
|
01 #P-CALLED-PROG (A8)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE GET-PROG-NAME
|
|
DECIDE ON FIRST VALUE OF #P-OBJECT-TYPE
|
|
VALUE 'genagree'
|
|
IF #L-LIST
|
|
#P-CALLED-PROG := 'WGEAGB0S'
|
|
ELSE
|
|
#P-CALLED-PROG := 'WGEAGX0S'
|
|
END-IF
|
|
VALUE 'Address'
|
|
#P-CALLED-PROG := 'WADDRB0S'
|
|
NONE VALUE
|
|
IGNORE
|
|
END-DECIDE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("KDWWIFN0_SAMPLE.nat", content);
|
|
|
|
AstNode prog = findNode(result, NodeType.VARIABLE, "#P-CALLED-PROG");
|
|
// Each guarded assignment as "whenField|whenValue->program" (genagree maps to BOTH the list
|
|
// and detail programs; Address to WADDRB0S; the NONE/IGNORE branch contributes nothing).
|
|
List<String> routes = new ArrayList<>();
|
|
for (var e : result.edges()) {
|
|
if (e.type() != EdgeType.WRITES || !e.targetId().equals(prog.id())) {
|
|
continue;
|
|
}
|
|
Map<String, String> props = e.properties();
|
|
if (props == null || props.get("whenValue") == null) {
|
|
continue;
|
|
}
|
|
String value = e.value() == null ? "" : e.value().replace("'", "").trim();
|
|
routes.add(props.get("whenField") + "|" + props.get("whenValue") + "->" + value);
|
|
}
|
|
assertEquals(3, routes.size(), "all three branch assignments must be guarded");
|
|
assertTrue(routes.contains("#P-OBJECT-TYPE|genagree->WGEAGB0S"));
|
|
assertTrue(routes.contains("#P-OBJECT-TYPE|genagree->WGEAGX0S"));
|
|
assertTrue(routes.contains("#P-OBJECT-TYPE|Address->WADDRB0S"));
|
|
}
|
|
|
|
@Test
|
|
void multiValueBranchDropsBlankAlternativeFromGuard() {
|
|
// A `VALUE 'X', ' '` branch (real literal + a Natural "also match blank" catch) must yield a
|
|
// clean guard value 'X', not the artifact 'X, ' with the blank swallowed (roadmap #55). A branch
|
|
// with two real literals stays comma-joined.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #P-DESC-NAME (A32)
|
|
01 #P-KEY (A8)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE ROUTE
|
|
DECIDE ON FIRST VALUE OF #P-DESC-NAME
|
|
VALUE 'GENAGREE-WOUT-SP', ' '
|
|
#P-KEY := 'A'
|
|
VALUE 'PHON-A', 'PHON-B'
|
|
#P-KEY := 'B'
|
|
NONE VALUE
|
|
IGNORE
|
|
END-DECIDE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("ROUTE_SAMPLE.nat", content);
|
|
AstNode key = findNode(result, NodeType.VARIABLE, "#P-KEY");
|
|
|
|
List<String> guards = new ArrayList<>();
|
|
for (var e : result.edges()) {
|
|
if (e.type() != EdgeType.WRITES || !e.targetId().equals(key.id())) {
|
|
continue;
|
|
}
|
|
Map<String, String> props = e.properties();
|
|
if (props != null && props.get("whenValue") != null) {
|
|
guards.add(props.get("whenValue"));
|
|
}
|
|
}
|
|
// The blank alternative is dropped (clean guard), the two-real-literal branch stays joined.
|
|
assertTrue(guards.contains("GENAGREE-WOUT-SP"), "blank alternative must be dropped: " + guards);
|
|
assertFalse(guards.contains("GENAGREE-WOUT-SP, "), "no trailing-blank artifact: " + guards);
|
|
assertTrue(guards.contains("PHON-A, PHON-B"), "two real literals stay comma-joined: " + guards);
|
|
}
|
|
|
|
@Test
|
|
void doubleHashFieldNamesAreNotTruncated() {
|
|
// ##-prefixed names (message/global fields, e.g. in CDPDA-M) must be captured whole, not
|
|
// truncated to "#" by the data-area field regex (P1-y).
|
|
String content = """
|
|
* synthetic copybook with double-hash fields
|
|
1MSG-INFO
|
|
A 79 2##MSG
|
|
N 4 2##MSG-NR
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("MSGDH.pda", content);
|
|
|
|
assertTrue(hasNode(result, NodeType.VARIABLE, "##MSG"), "##MSG must be parsed whole");
|
|
assertTrue(hasNode(result, NodeType.VARIABLE, "##MSG-NR"), "##MSG-NR must be parsed whole");
|
|
assertFalse(hasNode(result, NodeType.VARIABLE, "#"), "no field should be truncated to '#'");
|
|
}
|
|
|
|
@Test
|
|
void parsesPdaWithRedefineGroup() throws IOException {
|
|
String content = readFixture("WGEAGL01_SAMPLE.pda");
|
|
|
|
LanguageParser.ParseResult result = parser.parse("WGEAGL01_SAMPLE.pda", content);
|
|
|
|
// The group name (WGEAGL01) differs from the file name (WGEAGL01_SAMPLE), so the area is
|
|
// wrapped in a file-named root DATA_STRUCTURE for USING resolution (P1-s); the group nests
|
|
// under it, and the wrapper is the actual top-level node.
|
|
AstNode fileRoot = findNode(result, NodeType.DATA_STRUCTURE, "WGEAGL01_SAMPLE");
|
|
AstNode root = findNode(result, NodeType.DATA_STRUCTURE, "WGEAGL01");
|
|
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(fileRoot.id())));
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, fileRoot, "WGEAGL01", NodeType.DATA_STRUCTURE));
|
|
|
|
// plain field and its REDEFINE group are siblings under the WGEAGL01 group, same name, different types
|
|
AstNode addParmField = findNode(result, NodeType.VARIABLE, "#P-ADD-PARM");
|
|
assertEquals("A250", addParmField.dataType());
|
|
AstNode redefineGroup = findNode(result, NodeType.DATA_STRUCTURE, "#P-ADD-PARM");
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, root, addParmField.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, root, redefineGroup.name(), NodeType.DATA_STRUCTURE));
|
|
|
|
// level-3 fields nest under the REDEFINE group
|
|
AstNode codEmisor = findNode(result, NodeType.VARIABLE, "COD-EMISOR");
|
|
assertEquals("A8", codEmisor.dataType());
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, redefineGroup, "COD-EMISOR", NodeType.VARIABLE));
|
|
AstNode datStart = findNode(result, NodeType.VARIABLE, "DAT-START");
|
|
assertEquals("N8", datStart.dataType());
|
|
}
|
|
|
|
@Test
|
|
void bareAssignWithoutKeywordIsRecognized() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #SOURCE (A10)
|
|
01 #TARGET (A10)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SET-TARGET
|
|
#TARGET := #SOURCE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("BARE_ASSIGN_SAMPLE.nat", content);
|
|
|
|
AstNode setTarget = findNode(result, NodeType.FUNCTION, "SET-TARGET");
|
|
AstNode source = findNode(result, NodeType.VARIABLE, "#SOURCE");
|
|
AstNode target = findNode(result, NodeType.VARIABLE, "#TARGET");
|
|
assertTrue(hasEdge(result, EdgeType.READS, setTarget, source.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, setTarget, target.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void qualifiedAssignTargetMatchesUnqualifiedDeclaredField() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #SOURCE (A10)
|
|
01 SORT-KEY (A32)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SET-SORT-KEY
|
|
STRUCT.SORT-KEY := #SOURCE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("QUALIFIED_ASSIGN_SAMPLE.nat", content);
|
|
|
|
AstNode setSortKey = findNode(result, NodeType.FUNCTION, "SET-SORT-KEY");
|
|
AstNode sortKey = findNode(result, NodeType.VARIABLE, "SORT-KEY");
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, setSortKey, sortKey.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void addStatementReadsAndWritesAccumulator() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #COUNT (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE INCREMENT
|
|
ADD 1 TO #COUNT
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("ADD_SAMPLE.nat", content);
|
|
|
|
AstNode increment = findNode(result, NodeType.FUNCTION, "INCREMENT");
|
|
AstNode count = findNode(result, NodeType.VARIABLE, "#COUNT");
|
|
assertTrue(hasEdge(result, EdgeType.READS, increment, count.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, increment, count.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void addStatementWithGivingWritesOnlyTheTarget() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (I4)
|
|
01 #B (I4)
|
|
01 #SUM (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SUM-VALUES
|
|
ADD #A #B GIVING #SUM
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("ADD_GIVING_SAMPLE.nat", content);
|
|
|
|
AstNode sumValues = findNode(result, NodeType.FUNCTION, "SUM-VALUES");
|
|
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
|
|
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
|
|
AstNode sum = findNode(result, NodeType.VARIABLE, "#SUM");
|
|
assertTrue(hasEdge(result, EdgeType.READS, sumValues, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, sumValues, b.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, sumValues, sum.name(), NodeType.VARIABLE));
|
|
assertFalse(hasEdge(result, EdgeType.READS, sumValues, sum.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void subtractStatementWithGivingReadsMinuendAndSubtrahendWritesGivingTarget() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (I4)
|
|
01 #B (I4)
|
|
01 #DIFF (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE COMPUTE-DIFF
|
|
SUBTRACT #B FROM #A GIVING #DIFF
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SUBTRACT_GIVING_SAMPLE.nat", content);
|
|
|
|
AstNode computeDiff = findNode(result, NodeType.FUNCTION, "COMPUTE-DIFF");
|
|
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
|
|
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
|
|
AstNode diff = findNode(result, NodeType.VARIABLE, "#DIFF");
|
|
assertTrue(hasEdge(result, EdgeType.READS, computeDiff, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, computeDiff, b.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, computeDiff, diff.name(), NodeType.VARIABLE));
|
|
assertFalse(hasEdge(result, EdgeType.WRITES, computeDiff, a.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void subtractStatementWithoutGivingReadsAndWritesTheMinuend() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (I4)
|
|
01 #B (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE DECREMENT
|
|
SUBTRACT #B FROM #A
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SUBTRACT_SAMPLE.nat", content);
|
|
|
|
AstNode decrement = findNode(result, NodeType.FUNCTION, "DECREMENT");
|
|
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
|
|
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
|
|
assertTrue(hasEdge(result, EdgeType.READS, decrement, b.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, decrement, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, decrement, a.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void multiplyStatementWithoutGivingReadsAndWritesOperand1() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (I4)
|
|
01 #FACTOR (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SCALE
|
|
MULTIPLY #A BY #FACTOR
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("MULTIPLY_SAMPLE.nat", content);
|
|
|
|
AstNode scale = findNode(result, NodeType.FUNCTION, "SCALE");
|
|
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
|
|
AstNode factor = findNode(result, NodeType.VARIABLE, "#FACTOR");
|
|
assertTrue(hasEdge(result, EdgeType.READS, scale, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, scale, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, scale, factor.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void multiplyStatementWithGivingWritesOnlyTheTarget() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (I4)
|
|
01 #FACTOR (I4)
|
|
01 #RESULT (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SCALE-INTO-RESULT
|
|
MULTIPLY #A BY #FACTOR GIVING #RESULT
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("MULTIPLY_GIVING_SAMPLE.nat", content);
|
|
|
|
AstNode scale = findNode(result, NodeType.FUNCTION, "SCALE-INTO-RESULT");
|
|
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
|
|
AstNode factor = findNode(result, NodeType.VARIABLE, "#FACTOR");
|
|
AstNode resultVar = findNode(result, NodeType.VARIABLE, "#RESULT");
|
|
assertTrue(hasEdge(result, EdgeType.READS, scale, a.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, scale, factor.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, scale, resultVar.name(), NodeType.VARIABLE));
|
|
assertFalse(hasEdge(result, EdgeType.WRITES, scale, a.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void divideStatementWithGivingAndRemainderWritesBothTargets() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #DIVISOR (I4)
|
|
01 #DIVIDEND (I4)
|
|
01 #QUOTIENT (I4)
|
|
01 #REMAINDER (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SPLIT
|
|
DIVIDE #DIVISOR INTO #DIVIDEND GIVING #QUOTIENT REMAINDER #REMAINDER
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DIVIDE_SAMPLE.nat", content);
|
|
|
|
AstNode split = findNode(result, NodeType.FUNCTION, "SPLIT");
|
|
AstNode divisor = findNode(result, NodeType.VARIABLE, "#DIVISOR");
|
|
AstNode dividend = findNode(result, NodeType.VARIABLE, "#DIVIDEND");
|
|
AstNode quotient = findNode(result, NodeType.VARIABLE, "#QUOTIENT");
|
|
AstNode remainder = findNode(result, NodeType.VARIABLE, "#REMAINDER");
|
|
assertTrue(hasEdge(result, EdgeType.READS, split, divisor.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, split, dividend.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, split, quotient.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, split, remainder.name(), NodeType.VARIABLE));
|
|
assertFalse(hasEdge(result, EdgeType.WRITES, split, dividend.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void divideStatementWithoutGivingReadsAndWritesTheDividend() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #DIVISOR (I4)
|
|
01 #DIVIDEND (I4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE HALVE
|
|
DIVIDE #DIVISOR INTO #DIVIDEND
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DIVIDE_NO_GIVING_SAMPLE.nat", content);
|
|
|
|
AstNode halve = findNode(result, NodeType.FUNCTION, "HALVE");
|
|
AstNode divisor = findNode(result, NodeType.VARIABLE, "#DIVISOR");
|
|
AstNode dividend = findNode(result, NodeType.VARIABLE, "#DIVIDEND");
|
|
assertTrue(hasEdge(result, EdgeType.READS, halve, divisor.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.READS, halve, dividend.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, halve, dividend.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void decideForIsRecognizedAsControlFlowBlock() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #COUNTER (I4)
|
|
01 #STATUS (A1)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE CLASSIFY
|
|
DECIDE FOR FIRST CONDITION
|
|
WHEN #COUNTER = 0
|
|
#STATUS := 'Z'
|
|
WHEN NONE
|
|
#STATUS := 'N'
|
|
END-DECIDE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DECIDE_SAMPLE.nat", content);
|
|
|
|
AstNode classify = findNode(result, NodeType.FUNCTION, "CLASSIFY");
|
|
AstNode status = findNode(result, NodeType.VARIABLE, "#STATUS");
|
|
|
|
AstNode decideBlock = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "DECIDE".equals(n.dataType()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No DECIDE block"));
|
|
assertTrue(decideBlock.value() != null && decideBlock.value().startsWith("DECIDE FOR FIRST CONDITION"));
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, classify, decideBlock.name(), NodeType.CONTROL_FLOW));
|
|
|
|
// Statements inside the DECIDE block still produce normal WRITES edges
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, classify, status.name(), NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void qualifiedFieldOfIncludedDataAreaResolvesToPlaceholderUnderThatStructure() {
|
|
String content = """
|
|
DEFINE DATA
|
|
PARAMETER USING CDBRPDA
|
|
LOCAL
|
|
01 #COMPARE-DATA
|
|
02 COMPARE
|
|
03 SORT-KEY (A32)
|
|
END-DEFINE
|
|
MOVE 'X' TO CDBRPDA.SORT-KEY
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SAMPLE.nat", content);
|
|
|
|
AstNode module = findNode(result, NodeType.MODULE, "SAMPLE");
|
|
AstNode cdbrpda = findNode(result, NodeType.DATA_STRUCTURE, "CDBRPDA");
|
|
assertEquals("", cdbrpda.sourceFile());
|
|
|
|
AstNode placeholderField = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY") && n.sourceFile().isEmpty())
|
|
.findFirst().orElseThrow(() -> new AssertionError("No placeholder SORT-KEY field"));
|
|
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, cdbrpda, placeholderField.name(), NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, module, placeholderField.name(), NodeType.VARIABLE));
|
|
|
|
// The local #COMPARE-DATA.COMPARE.SORT-KEY field is a distinct node and must not
|
|
// receive the WRITES edge meant for CDBRPDA.SORT-KEY.
|
|
AstNode localSortKey = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY") && !n.sourceFile().isEmpty())
|
|
.findFirst().orElseThrow(() -> new AssertionError("No local SORT-KEY field"));
|
|
assertNotEquals(localSortKey.id(), placeholderField.id());
|
|
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.WRITES
|
|
&& e.sourceId().equals(module.id()) && e.targetId().equals(localSortKey.id())));
|
|
}
|
|
|
|
@Test
|
|
void findWithRecordLimitIsNotMisparsedAsTableName() {
|
|
// P1-k: FIND (1) <view> must use <view> as the table name, not "(1)"
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE LOOKUP
|
|
FIND (1) MY_VIEW WITH COD-EMISOR = '123'
|
|
END-FIND
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("FIND_LIMIT_SAMPLE.nat", content);
|
|
|
|
AstNode lookup = findNode(result, NodeType.FUNCTION, "LOOKUP");
|
|
assertTrue(hasEdge(result, EdgeType.READS, lookup, "MY_VIEW", NodeType.DB_TABLE),
|
|
"Expected READS edge to MY_VIEW");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "(1)"),
|
|
"DB_TABLE named '(1)' must not exist");
|
|
}
|
|
|
|
@Test
|
|
void readWorkFileIsNotMisparsedAsDbTableWork() {
|
|
// Finding 2: `READ WORK FILE n <buffer>` is sequential work-file I/O, NOT an ADABAS READ of a
|
|
// view called "WORK". It must produce a WORKFILE (not a DB_TABLE) access.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE LOAD
|
|
READ WORK FILE 8 ONCE RECORD USIX053A
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("WORKFILE_READ_SAMPLE.nat", content);
|
|
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "WORK"),
|
|
"READ WORK FILE must not create a DB_TABLE named 'WORK'");
|
|
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
|
|
assertTrue(hasNode(result, NodeType.WORKFILE, "8"), "Expected WORKFILE node '8'");
|
|
assertTrue(hasEdge(result, EdgeType.READS, load, "8", NodeType.WORKFILE),
|
|
"Expected READS edge from LOAD to WORKFILE '8'");
|
|
AstNode access = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.WORKFILE_ACCESS && "8".equals(n.name()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No WORKFILE_ACCESS for file 8"));
|
|
assertEquals("READ", access.dataType());
|
|
assertEquals("USIX053A", access.properties() == null ? null : access.properties().get("recordBuffer"));
|
|
}
|
|
|
|
@Test
|
|
void writeWorkFileCreatesWorkfileAccessWithPhysicalName() {
|
|
// Finding 2 (WRITE side + DEFINE WORK FILE): WRITE WORK FILE creates a WORKFILE WRITES access,
|
|
// and DEFINE WORK FILE n '<name>' attaches the physical name to the WORKFILE node.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE WORK FILE 11 'CPRO-CHANGES.CSV'
|
|
DEFINE SUBROUTINE DUMP
|
|
WRITE WORK FILE 11 VARIABLE #WORK-RECORD11
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("WORKFILE_WRITE_SAMPLE.nat", content);
|
|
|
|
AstNode dump = findNode(result, NodeType.FUNCTION, "DUMP");
|
|
AstNode wf = findNode(result, NodeType.WORKFILE, "11");
|
|
assertEquals("CPRO-CHANGES.CSV", wf.properties() == null ? null : wf.properties().get("physicalName"));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, dump, "11", NodeType.WORKFILE),
|
|
"Expected WRITES edge from DUMP to WORKFILE '11'");
|
|
AstNode access = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.WORKFILE_ACCESS && "11".equals(n.name()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No WORKFILE_ACCESS for file 11"));
|
|
assertEquals("WRITE", access.dataType());
|
|
assertEquals("#WORK-RECORD11", access.properties() == null ? null : access.properties().get("recordBuffer"));
|
|
}
|
|
|
|
@Test
|
|
void findNumberResolvesToViewNotTheNumberKeyword() {
|
|
// Finding 3: `FIND NUMBER <view>` is a count-only FIND (natural-grammar.md §7.1); NUMBER is a
|
|
// statement keyword, not the accessed view. The DB table must be <view>, never "NUMBER".
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE COUNTER
|
|
FIND NUMBER MY_VIEW WITH COD = '1'
|
|
FIND NUMBER IN FILE OTHER_VIEW WITH COD = '2'
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("FIND_NUMBER_SAMPLE.nat", content);
|
|
|
|
AstNode counter = findNode(result, NodeType.FUNCTION, "COUNTER");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "NUMBER"),
|
|
"FIND NUMBER must not create a DB_TABLE named 'NUMBER'");
|
|
assertTrue(hasEdge(result, EdgeType.READS, counter, "MY_VIEW", NodeType.DB_TABLE),
|
|
"Expected READS edge to MY_VIEW");
|
|
assertTrue(hasEdge(result, EdgeType.READS, counter, "OTHER_VIEW", NodeType.DB_TABLE),
|
|
"IN FILE noise words must be skipped so OTHER_VIEW is the view");
|
|
}
|
|
|
|
@Test
|
|
void readWorkWithoutFileKeywordIsWorkfileNotDbTable() {
|
|
// Finding 4: `READ WORK n` (the FILE keyword omitted, as in `READ WORK 1 ONCE RECORD …`) is still
|
|
// work-file I/O, not an ADABAS READ of a view "WORK".
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 MIG-DATEN (A80)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE LOAD
|
|
READ WORK 1 ONCE RECORD MIG-DATEN
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("READ_WORK_NOFILE.nat", content);
|
|
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "WORK"), "READ WORK n must not create a DB_TABLE 'WORK'");
|
|
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
|
|
assertTrue(hasEdge(result, EdgeType.READS, load, "1", NodeType.WORKFILE), "Expected WORKFILE '1' READS");
|
|
}
|
|
|
|
@Test
|
|
void sqlDeleteFromResolvesToTheTableNotTheFromKeyword() {
|
|
// Finding 5: `DELETE FROM <table>` (SQL) — the table is the FROM operand, not the token right
|
|
// after DELETE. Must produce the real table, never a phantom `FROM`.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE PURGE
|
|
DELETE FROM VDB2_MY_TABLE WHERE COD = '1'
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DELETE_FROM_SAMPLE.nat", content);
|
|
|
|
AstNode purge = findNode(result, NodeType.FUNCTION, "PURGE");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "FROM"), "DELETE FROM must not create a DB_TABLE 'FROM'");
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, purge, "VDB2_MY_TABLE", NodeType.DB_TABLE),
|
|
"Expected WRITES edge to VDB2_MY_TABLE");
|
|
}
|
|
|
|
@Test
|
|
void sqlSelectColumnEndingInFromDoesNotShadowTheRealFromClause() {
|
|
// Finding (audit tail): a Natural SQL SELECT column list may contain a hyphenated field whose
|
|
// last segment is literally FROM (e.g. `DAT-CALC-FROM`). A `\bFROM` match treats the hyphen as
|
|
// a word boundary and captures the following operand `(*)` as a phantom table, and — because the
|
|
// FROM view is only bound on the first match — the real `FROM <view>` clause below is swallowed.
|
|
// The real view must be resolved; no phantom `(*)` table may appear.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 YCOMIROW VIEW OF VERSVW_COMISION
|
|
2 DAT-CALC-FROM (A8)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE LOAD
|
|
SELECT COD_EMISOR
|
|
, YCOMIROW.DAT-CALC-FROM (*)
|
|
INTO VIEW YCOMIROW
|
|
FROM VERSVW_COMISION
|
|
WHERE COD = '1'
|
|
END-SELECT
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SELECT_FROM_COLUMN.nat", content);
|
|
|
|
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "(*)"),
|
|
"SELECT column `-FROM (*)` must not create a phantom DB_TABLE '(*)'");
|
|
assertTrue(hasEdge(result, EdgeType.READS, load, "VERSVW_COMISION", NodeType.DB_TABLE),
|
|
"Expected READS edge to the real FROM view VERSVW_COMISION");
|
|
}
|
|
|
|
@Test
|
|
void naturalDeleteWithoutFromNamesNoTable() {
|
|
// Finding 5: Natural DML `DELETE [(label)]` deletes the current loop record and names no view;
|
|
// and the EXAMINE clause `… DELETE FIRST` is not a DELETE statement at all. Neither may create a
|
|
// DB_TABLE ('OLD', a label, or 'FIRST').
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 #W-VIEW (A32)
|
|
1 #K (A4)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE CLEAN
|
|
DELETE (OLD.)
|
|
EXAMINE #W-VIEW FOR #K
|
|
DELETE FIRST
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DELETE_NOFROM_SAMPLE.nat", content);
|
|
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "FIRST"), "EXAMINE … DELETE FIRST is not a DB DELETE");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "(OLD.)"), "DELETE (label) names no table");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "OLD"), "DELETE (label) names no table");
|
|
}
|
|
|
|
@Test
|
|
void updateWithLabelReferenceNamesNoTable() {
|
|
// Finding 5 (tail): `UPDATE (label)` updates the current record of the enclosing loop via a
|
|
// reference label — no view. A view never starts with '(', so it must not become a table.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SAVE
|
|
UPDATE (OLD.)
|
|
UPDATE VERSVW_REAL
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("UPDATE_LABEL_SAMPLE.nat", content);
|
|
|
|
AstNode save = findNode(result, NodeType.FUNCTION, "SAVE");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "(OLD.)"), "UPDATE (label) names no table");
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, save, "VERSVW_REAL", NodeType.DB_TABLE),
|
|
"UPDATE <view> still records the real view");
|
|
}
|
|
|
|
@Test
|
|
void viewAliasResolvesToTheUnderlyingTable() {
|
|
// Audit defect A: a Natural DML operand is a view *variable* (`1 <alias> VIEW OF <table>`), not the
|
|
// table. Reporting the alias mints a phantom DB_TABLE and splits one table across several names —
|
|
// in upms a single node `NEXT-VIEW` stood for 11 different tables.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 NEXT-VIEW VIEW OF T_REAL
|
|
2 REC-ID (N10)
|
|
1 VDB2-T_REAL VIEW OF T_REAL
|
|
2 REC-ID (N10)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE ACCESS-IT
|
|
FIND NUMBER NEXT-VIEW
|
|
WITH REC-ID = 1
|
|
FIND VDB2-T_REAL WITH
|
|
REC-ID = 2
|
|
END-FIND
|
|
STORE VDB2-T_REAL
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("VIEW_ALIAS_SAMPLE.nat", content);
|
|
|
|
AstNode access = findNode(result, NodeType.FUNCTION, "ACCESS-IT");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "NEXT-VIEW"),
|
|
"The view variable NEXT-VIEW must not become a DB_TABLE");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "VDB2-T_REAL"),
|
|
"The view variable VDB2-T_REAL must not become a DB_TABLE");
|
|
assertTrue(hasEdge(result, EdgeType.READS, access, "T_REAL", NodeType.DB_TABLE),
|
|
"FIND through a view alias must READ the underlying table");
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, access, "T_REAL", NodeType.DB_TABLE),
|
|
"STORE through a view alias must WRITE the underlying table");
|
|
assertEquals(List.of(8, 10), readLines(result, access, "T_REAL"),
|
|
"Both FIND variants (incl. FIND NUMBER) must resolve to T_REAL");
|
|
}
|
|
|
|
@Test
|
|
void updateAndDeleteByReferenceResolveToTheEnclosingLoopTable() {
|
|
// Audit defect B: `UPDATE(label.)` / `DELETE(label.)` write the current record of the labelled
|
|
// FIND/READ loop. Dropping them (to avoid a phantom `(label.)` table) made the whole Y****MN0 CRUD
|
|
// layer look read-only. The loop operand is itself a view alias, so A and B compose.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 VDB2-T_REAL VIEW OF T_REAL
|
|
2 REC-ID (N10)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE HOLD-OBJECT
|
|
HOLD-PRIME.
|
|
FIND VDB2-T_REAL WITH
|
|
REC-ID = 1
|
|
UPDATE(HOLD-PRIME.)
|
|
DELETE(HOLD-PRIME.)
|
|
END-FIND
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("BY_REF_SAMPLE.nat", content);
|
|
|
|
AstNode hold = findNode(result, NodeType.FUNCTION, "HOLD-OBJECT");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "HOLD-PRIME"),
|
|
"The statement label must not become a DB_TABLE");
|
|
assertEquals(List.of(9, 10), writeLines(result, hold, "T_REAL"),
|
|
"UPDATE(label.) and DELETE(label.) must WRITE the loop's table");
|
|
}
|
|
|
|
@Test
|
|
void byReferenceWriteResolvesThroughALabelledSelectLoop() {
|
|
// Audit defect B, second shape: the generated access layer holds its record with a labelled SQL
|
|
// SELECT as often as with a FIND (YELEMMN0, YMULTMN0). The table is the FROM operand.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 VDB2-T_REAL VIEW OF T_REAL
|
|
2 REC-ID (N10)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE HOLD-OBJECT
|
|
HOLD-PRIME.
|
|
SELECT *
|
|
INTO VIEW VDB2-T_REAL
|
|
FROM T_REAL
|
|
WHERE REC-ID = 1
|
|
UPDATE(HOLD-PRIME.)
|
|
DELETE(HOLD-PRIME.)
|
|
END-SELECT
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SELECT_LABEL_SAMPLE.nat", content);
|
|
|
|
AstNode hold = findNode(result, NodeType.FUNCTION, "HOLD-OBJECT");
|
|
assertEquals(List.of(11, 12), writeLines(result, hold, "T_REAL"),
|
|
"A by-reference write must resolve through a labelled SELECT loop too");
|
|
}
|
|
|
|
@Test
|
|
void byReferenceWriteWithoutAResolvableLoopNamesNoTable() {
|
|
// The no-phantom guarantee must survive the defect-B fix: an unmatched label, and the numeric
|
|
// source-line form `UPDATE (r)` that Natural also allows, resolve to nothing rather than to a
|
|
// guessed table.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 VDB2-T_REAL VIEW OF T_REAL
|
|
2 REC-ID (N10)
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE SAVE
|
|
UPDATE(NO-SUCH-LABEL.)
|
|
DELETE(0100)
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("BY_REF_UNRESOLVED_SAMPLE.nat", content);
|
|
|
|
AstNode save = findNode(result, NodeType.FUNCTION, "SAVE");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "NO-SUCH-LABEL"), "Unmatched label names no table");
|
|
assertFalse(hasNode(result, NodeType.DB_TABLE, "0100"), "A source-line reference names no table");
|
|
assertTrue(writeLines(result, save, "T_REAL").isEmpty(),
|
|
"An unresolvable by-reference write must not be attributed to any table");
|
|
}
|
|
|
|
@Test
|
|
void multiLineFindStatementTextIsCapturedFully() {
|
|
// P1-j: FIND spanning multiple lines must collect all lines until END-FIND
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
END-DEFINE
|
|
DEFINE SUBROUTINE FETCH
|
|
FIND MY_TABLE WITH
|
|
COD-EMISOR = '123'
|
|
AND NIF-PERSONA = '456'
|
|
END-FIND
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("MULTILINE_FIND_SAMPLE.nat", content);
|
|
|
|
AstNode findAccess = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.DB_ACCESS && "READ".equals(n.dataType()))
|
|
.findFirst().orElseThrow(() -> new AssertionError("No READ DB_ACCESS node"));
|
|
|
|
assertNotNull(findAccess.value(), "DB_ACCESS value must not be null");
|
|
assertTrue(findAccess.value().contains("COD-EMISOR"),
|
|
"Statement text must include continuation line COD-EMISOR");
|
|
assertTrue(findAccess.value().contains("END-FIND"),
|
|
"Statement text must include END-FIND terminator");
|
|
}
|
|
|
|
@Test
|
|
void pdaViewOfCreatesUsesTypeEdgeToDbTable() {
|
|
// P1-i: VIEW OF <table> in a PDA creates USES_TYPE edge from DATA_STRUCTURE to DB_TABLE.
|
|
// Content uses the PDA export format: [format] [length] [level][name]
|
|
String content = """
|
|
*-----------------------------------------------------------------
|
|
* Test PDA: view of CUST_TABLE
|
|
*-----------------------------------------------------------------
|
|
1ADDR-VIEW VIEW OF CUST_TABLE
|
|
A 10 2CUST-ID
|
|
A 40 2CUST-NAME
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("ADDR_VIEW.pda", content);
|
|
|
|
AstNode viewStruct = findNode(result, NodeType.DATA_STRUCTURE, "ADDR-VIEW");
|
|
assertTrue(hasNode(result, NodeType.DB_TABLE, "CUST_TABLE"),
|
|
"DB_TABLE CUST_TABLE must be created");
|
|
assertTrue(hasEdge(result, EdgeType.USES_TYPE, viewStruct, "CUST_TABLE", NodeType.DB_TABLE),
|
|
"USES_TYPE edge from ADDR-VIEW to CUST_TABLE must exist");
|
|
assertTrue(hasNode(result, NodeType.VARIABLE, "CUST-ID"),
|
|
"Column CUST-ID must be parsed as VARIABLE");
|
|
}
|
|
|
|
@Test
|
|
void callnatArgumentsAreCapturedOnTheCallsEdge() throws IOException {
|
|
LanguageParser.ParseResult result = parser.parse("DF_CALLER.nat", readFixture("DF_CALLER.nat"));
|
|
|
|
AstNode callee = findNode(result, NodeType.MODULE, "DF_CALLEE");
|
|
com.agenticcode.parsercore.ast.model.AstEdge call = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(callee.id()))
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(call.properties());
|
|
assertEquals("#CLIENT", call.properties().get("args"));
|
|
}
|
|
|
|
@Test
|
|
void dynamicCallnatRecordsPlaceholderAndMarkerWithoutShadowingLiteralCallnat() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL
|
|
1 #WIF (A8)
|
|
END-DEFINE
|
|
#WIF := 'WTARGET'
|
|
CALLNAT #WIF #ARG1 #ARG2
|
|
CALLNAT 'WLITERAL' #ARG1
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("DYN_CALLER.nat", content);
|
|
|
|
// The dynamic call yields a placeholder MODULE named after the dispatch variable, plus a
|
|
// CALLS edge tagged CALLNAT_DYNAMIC carrying the variable name and the trailing args.
|
|
AstNode dynTarget = findNode(result, NodeType.MODULE, "#WIF");
|
|
com.agenticcode.parsercore.ast.model.AstEdge dynCall = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(dynTarget.id()))
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(dynCall.properties());
|
|
assertEquals("CALLNAT_DYNAMIC", dynCall.properties().get("callKind"));
|
|
assertEquals("#WIF", dynCall.properties().get("dynamicVar"));
|
|
assertEquals("#ARG1,#ARG2", dynCall.properties().get("args"));
|
|
|
|
// The literal CALLNAT on the next line is unaffected (no shadowing): still a MODULE target
|
|
// tagged plain CALLNAT.
|
|
AstNode litTarget = findNode(result, NodeType.MODULE, "WLITERAL");
|
|
com.agenticcode.parsercore.ast.model.AstEdge litCall = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(litTarget.id()))
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(litCall.properties());
|
|
assertEquals("CALLNAT", litCall.properties().get("callKind"));
|
|
assertEquals("#ARG1", litCall.properties().get("args"));
|
|
}
|
|
|
|
@Test
|
|
void multiLineCallnatArgumentsAreCapturedInOrderUntilNextStatement() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL
|
|
1 #A (A8)
|
|
1 #B (A8)
|
|
1 #C (A8)
|
|
END-DEFINE
|
|
CALLNAT 'TARGETMOD' #A
|
|
#B
|
|
#C
|
|
*
|
|
MOVE #A TO #B
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("MLCALL.nat", content);
|
|
|
|
AstNode callee = findNode(result, NodeType.MODULE, "TARGETMOD");
|
|
com.agenticcode.parsercore.ast.model.AstEdge call = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(callee.id()))
|
|
.findFirst().orElseThrow();
|
|
assertNotNull(call.properties());
|
|
// Args span three physical lines; the comment line terminates the list before MOVE.
|
|
assertEquals("#A,#B,#C", call.properties().get("args"));
|
|
}
|
|
|
|
@Test
|
|
void defineDataFieldsCarryTheirDeclaredScope() {
|
|
String content = """
|
|
DEFINE DATA
|
|
PARAMETER
|
|
1 #P-IN (A8)
|
|
LOCAL
|
|
1 #L-VAR (A4)
|
|
END-DEFINE
|
|
*
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("SCOPED.nat", content);
|
|
|
|
AstNode pin = findNode(result, NodeType.VARIABLE, "#P-IN");
|
|
AstNode lvar = findNode(result, NodeType.VARIABLE, "#L-VAR");
|
|
assertNotNull(pin.properties());
|
|
assertNotNull(lvar.properties());
|
|
assertEquals("PARAMETER", pin.properties().get("scope"));
|
|
assertEquals("LOCAL", lvar.properties().get("scope"));
|
|
}
|
|
|
|
@Test
|
|
void parameterUsingAreasAdvanceInlineParameterPositions() {
|
|
String content = """
|
|
DEFINE DATA
|
|
PARAMETER USING AREA1
|
|
PARAMETER USING AREA2
|
|
PARAMETER
|
|
1 #P-FIRST (A8)
|
|
1 #P-SECOND (A1)
|
|
END-DEFINE
|
|
*
|
|
END
|
|
""";
|
|
|
|
LanguageParser.ParseResult result = parser.parse("PCALLEE.nat", content);
|
|
|
|
// The two PARAMETER USING areas occupy positions 0 and 1, so the inline params follow at 2/3
|
|
// — matching the caller's positional CALLNAT arguments.
|
|
AstNode first = findNode(result, NodeType.VARIABLE, "#P-FIRST");
|
|
AstNode second = findNode(result, NodeType.VARIABLE, "#P-SECOND");
|
|
assertNotNull(first.properties());
|
|
assertNotNull(second.properties());
|
|
assertEquals("2", first.properties().get("paramPosition"));
|
|
assertEquals("3", second.properties().get("paramPosition"));
|
|
}
|
|
|
|
@Test
|
|
void bareReferenceToIncludedFieldCreatesModuleLevelPlaceholder() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL USING SOMEAREA
|
|
LOCAL
|
|
1 #X (A8)
|
|
END-DEFINE
|
|
MOVE #X TO SORT-KEY
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("BARE_CALLER.nat", content);
|
|
|
|
AstNode module = findNode(result, NodeType.MODULE, "BARE_CALLER");
|
|
// SORT-KEY is unqualified and not local, but the module has a USING include ->
|
|
// a module-level placeholder field is created (to be resolved against SOMEAREA in Neo4j).
|
|
AstNode sortKey = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY"))
|
|
.findFirst().orElseThrow();
|
|
assertEquals("", sortKey.sourceFile());
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "SORT-KEY", NodeType.VARIABLE));
|
|
assertTrue(hasEdge(result, EdgeType.WRITES, module, "SORT-KEY", NodeType.VARIABLE));
|
|
}
|
|
|
|
@Test
|
|
void bareUnknownIsNotPromotedWithoutIncludes() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL
|
|
1 #X (A8)
|
|
END-DEFINE
|
|
MOVE #X TO SORT-KEY
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("NOINC.nat", content);
|
|
|
|
// No USING include -> bare SORT-KEY must NOT become a placeholder.
|
|
assertFalse(hasNode(result, NodeType.VARIABLE, "SORT-KEY"));
|
|
}
|
|
|
|
@Test
|
|
void parameterFieldsAreTaggedWithPosition() throws IOException {
|
|
LanguageParser.ParseResult result = parser.parse("DF_CALLEE.nat", readFixture("DF_CALLEE.nat"));
|
|
|
|
AstNode pClient = findNode(result, NodeType.VARIABLE, "#P-CLIENT");
|
|
assertNotNull(pClient.properties());
|
|
assertEquals("0", pClient.properties().get("paramPosition"));
|
|
|
|
AstNode pStatus = findNode(result, NodeType.VARIABLE, "#P-STATUS");
|
|
assertNotNull(pStatus.properties());
|
|
assertEquals("1", pStatus.properties().get("paramPosition"));
|
|
}
|
|
|
|
@Test
|
|
void capturesSubscriptedArrayAssignmentAndCopyForDynamicResolution() {
|
|
// A dynamic-dispatch routing-table pattern: a literal is assigned to an array element
|
|
// (note the space before the subscript), the dispatch variable is copied from that array,
|
|
// then called indirectly. The parser must capture the subscripted literal write and read
|
|
// the array on the copy line — the two facts the indirect dynamic-CALLNAT resolver joins on.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 #TBL (A8/1:10)
|
|
1 #W-ACT-PROG (A8)
|
|
1 #I (I4)
|
|
END-DEFINE
|
|
ASSIGN #TBL (1) = 'WPARTD2S'
|
|
#W-ACT-PROG := #TBL (#I)
|
|
CALLNAT #W-ACT-PROG
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("DISP.nat", content);
|
|
AstNode tbl = findNode(result, NodeType.VARIABLE, "#TBL");
|
|
AstNode dispatchVar = findNode(result, NodeType.VARIABLE, "#W-ACT-PROG");
|
|
|
|
// 1) the subscripted ASSIGN records a WRITES to the array carrying the literal value
|
|
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.WRITES
|
|
&& e.targetId().equals(tbl.id()) && "'WPARTD2S'".equals(e.value())),
|
|
"subscripted ASSIGN should record a WRITES to #TBL carrying the literal 'WPARTD2S'");
|
|
|
|
// 2) the copy WRITES the dispatch variable and READS the array on the same line
|
|
int copyLine = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.WRITES && e.targetId().equals(dispatchVar.id()))
|
|
.mapToInt(e -> e.lineNo()).min().orElse(-1);
|
|
assertTrue(copyLine > 0, "the dispatch-var copy should record a WRITES to #W-ACT-PROG");
|
|
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.READS
|
|
&& e.targetId().equals(tbl.id()) && e.lineNo() == copyLine),
|
|
"the dispatch-var copy should READ #TBL on the same line it WRITES the dispatch var");
|
|
}
|
|
|
|
// --- item 44: framework include-macro ------------------------------------------------------
|
|
|
|
@Test
|
|
void frameworkIncludeMacroEmitsCallToDequotedAccessor() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL USING FRELEMK
|
|
LOCAL USING FRELEMV
|
|
END-DEFINE
|
|
INCLUDE YFRAMGC0 'FRELEMV.C-MOD-GET' '"CO-ELEMENTO-ALT"'
|
|
'"FRELEMG0"' 'FRELEMK' 'FRELEMV'
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("FRAMGET.nat", content);
|
|
AstNode module = findNode(result, NodeType.MODULE, "FRAMGET");
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "FRELEMG0", NodeType.MODULE),
|
|
"INCLUDE YFRAMGC0 must emit a CALLS edge to the de-quoted accessor FRELEMG0 (arg 2)");
|
|
// The macro name itself is not a call target, and the other quoted args are not accessors.
|
|
assertFalse(hasNode(result, NodeType.MODULE, "YFRAMGC0"));
|
|
assertFalse(hasNode(result, NodeType.MODULE, "FRELEMK"));
|
|
}
|
|
|
|
@Test
|
|
void unknownStatementLevelIncludeProducesNoCall() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #X (A1)
|
|
END-DEFINE
|
|
INCLUDE SOMECOPY 'ARG1' 'ARG2'
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("CLIENT.nat", content);
|
|
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CALLS),
|
|
"an INCLUDE of an unknown (non-framework) copycode must not be turned into a call");
|
|
}
|
|
|
|
// --- item 45: XML payload contract ---------------------------------------------------------
|
|
|
|
@Test
|
|
void extractsXmlPayloadTriplesFromAddXmlLineIdiom() {
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL USING WXMLIN
|
|
LOCAL
|
|
01 #W-TAG (A32)
|
|
01 #W-VALUE (A50)
|
|
01 #W-LINE (A100)
|
|
END-DEFINE
|
|
PERFORM BUILD-PAYLOAD
|
|
DEFINE SUBROUTINE BUILD-PAYLOAD
|
|
#W-TAG := 'cod_usuario'
|
|
#W-VALUE := WXMLIN.P-COD-USUARIO
|
|
PERFORM ADD-XML-LINE
|
|
#W-TAG := 'nif_persona'
|
|
#W-VALUE := WXMLIN.P-NIF-PERSONA
|
|
PERFORM ADD-XML-LINE
|
|
END-SUBROUTINE
|
|
DEFINE SUBROUTINE ADD-XML-LINE
|
|
COMPRESS '<' #W-TAG '>' #W-VALUE '</' #W-TAG '>' INTO #W-LINE LEAVING NO
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("WNXMLD0S.nat", content);
|
|
List<AstNode> payloads = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.PAYLOAD_FIELD).toList();
|
|
assertEquals(2, payloads.size(), "one PAYLOAD_FIELD per emitted tag");
|
|
|
|
AstNode cod = payloads.stream().filter(n -> "cod_usuario".equals(n.name())).findFirst().orElseThrow();
|
|
Map<String, String> props = java.util.Objects.requireNonNull(cod.properties());
|
|
assertEquals("cod_usuario", props.get("tag"));
|
|
assertEquals("P-COD-USUARIO", props.get("field"), "qualifier stripped to the field name");
|
|
assertEquals("REQUEST", props.get("direction"), "COMPRESS-emit is an outbound (REQUEST) field");
|
|
|
|
// Each triple is contained by the module.
|
|
AstNode module = findNode(result, NodeType.MODULE, "WNXMLD0S");
|
|
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "nif_persona", NodeType.PAYLOAD_FIELD));
|
|
}
|
|
|
|
// --- item 46a: copycode (.cpy) expansion ---------------------------------------------------
|
|
|
|
@Test
|
|
void copycodeExpansionAttributesCallToHostWithCopycodeLineAndViaMarker() {
|
|
String host = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (A1)
|
|
END-DEFINE
|
|
PERFORM SETUP
|
|
INCLUDE CALLC2 '5'
|
|
IF #A = ' '
|
|
IGNORE
|
|
END-IF
|
|
DEFINE SUBROUTINE SETUP
|
|
IGNORE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
String copycode = """
|
|
* a call-mediating copycode
|
|
CALLNAT 'TARGETX' #A &1&
|
|
""";
|
|
CopycodeResolver resolver = name -> "CALLC2".equalsIgnoreCase(name)
|
|
? new CopycodeResolver.Copycode("copycode/CALLC2.cpy", copycode)
|
|
: null;
|
|
|
|
LanguageParser.ParseResult result = parser.parse("JE0005N0.nat", host, resolver);
|
|
|
|
AstNode module = findNode(result, NodeType.MODULE, "JE0005N0");
|
|
// The CALLNAT hidden in the copycode is now a callee of the host module.
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "TARGETX", NodeType.MODULE));
|
|
|
|
AstEdge callEdge = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS)
|
|
.filter(e -> result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId())
|
|
&& "TARGETX".equals(n.name())))
|
|
.findFirst().orElseThrow();
|
|
// Line points INTO the copycode file (line 2 of CALLC2.cpy), tagged with its origin.
|
|
assertEquals(2, callEdge.lineNo(), "CALLNAT line must resolve into the copycode, not the host INCLUDE line");
|
|
assertNotNull(callEdge.properties());
|
|
assertEquals("CALLC2", callEdge.properties().get("viaCopycode"));
|
|
assertEquals("5", callEdge.properties().get("includedAt"), "the host INCLUDE is on line 5");
|
|
// Item 66: the edge must also name the file its lineNo belongs to. Nodes carry that in
|
|
// sourceFile; an edge has no such field, so without originFile the API can only pair the
|
|
// copycode's line with the host's file — an explicitly wrong location.
|
|
assertEquals("copycode/CALLC2.cpy", callEdge.properties().get("originFile"),
|
|
"lineNo 2 belongs to the copycode, so the edge must carry that file's real path");
|
|
|
|
// A host statement AFTER the INCLUDE keeps its real host line (6), i.e. no line-shift leakage.
|
|
AstNode ifBlock = findNode(result, NodeType.CONTROL_FLOW, "IF");
|
|
assertEquals(6, ifBlock.startLine(), "host IF must stay on its original line despite the splice");
|
|
// PERFORM SETUP before the INCLUDE is unaffected (host line 4).
|
|
AstEdge perform = result.edges().stream()
|
|
.filter(e -> e.type() == EdgeType.CALLS)
|
|
.filter(e -> result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId())
|
|
&& "SETUP".equals(n.name())))
|
|
.findFirst().orElseThrow();
|
|
assertEquals(4, perform.lineNo());
|
|
}
|
|
|
|
@Test
|
|
void unknownCopycodeIsLeftAsIsWithoutExpansion() {
|
|
String host = """
|
|
DEFINE DATA LOCAL
|
|
01 #A (A1)
|
|
END-DEFINE
|
|
INCLUDE NOSUCHCOPY '5'
|
|
END
|
|
""";
|
|
// NONE resolver: nothing is expanded; the unknown INCLUDE yields no call (not a framework macro).
|
|
LanguageParser.ParseResult result = parser.parse("PROG.nat", host, CopycodeResolver.NONE);
|
|
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CALLS));
|
|
// The 2-arg SPI delegates to NONE, producing the same node/edge shape (identity remap).
|
|
LanguageParser.ParseResult viaSpi = parser.parse("PROG.nat", host);
|
|
assertEquals(result.nodes().size(), viaSpi.nodes().size());
|
|
assertEquals(result.edges().size(), viaSpi.edges().size());
|
|
}
|
|
|
|
// --- item 46c: Global Data Areas (.gda) ----------------------------------------------------
|
|
|
|
@Test
|
|
void globalUsingResolvesToIncludedDataArea() {
|
|
String content = """
|
|
DEFINE DATA
|
|
GLOBAL USING MYGDA
|
|
LOCAL
|
|
01 #X (A1)
|
|
END-DEFINE
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("PROG.nat", content);
|
|
AstNode module = findNode(result, NodeType.MODULE, "PROG");
|
|
assertTrue(hasEdge(result, EdgeType.INCLUDES, module, "MYGDA", NodeType.DATA_STRUCTURE),
|
|
"GLOBAL USING must create an INCLUDES edge to the GDA, like PARAMETER/LOCAL USING");
|
|
}
|
|
|
|
@Test
|
|
void gdaFileParsesAsDataAreaNotModule() {
|
|
String content = """
|
|
* a global data area
|
|
1MYGDA
|
|
A 10 2#COMPANY
|
|
N 4 2#YEAR
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("MYGDA.gda", content);
|
|
assertTrue(hasNode(result, NodeType.VARIABLE, "#COMPANY"));
|
|
assertTrue(hasNode(result, NodeType.VARIABLE, "#YEAR"));
|
|
assertFalse(result.nodes().stream().anyMatch(n -> n.type() == NodeType.MODULE),
|
|
"a .gda is a data area, not a module");
|
|
}
|
|
|
|
@Test
|
|
void staticIdiomSupportsDerivedTagsAndResponseDirection() {
|
|
// REQUEST with a derived tag (#COD -> _COD via EXAMINE) and a RESPONSE via GET-XML-LINE.
|
|
String content = """
|
|
DEFINE DATA
|
|
LOCAL USING WXMLIN
|
|
LOCAL
|
|
01 #W-TAG (A32)
|
|
01 #W-VALUE (A50)
|
|
01 #W-LINE (A100)
|
|
END-DEFINE
|
|
*
|
|
PERFORM BUILD-PAYLOAD
|
|
DEFINE SUBROUTINE BUILD-PAYLOAD
|
|
#W-TAG := '#COD-USUARIO'
|
|
#W-VALUE := WXMLIN.P-COD-USUARIO
|
|
EXAMINE #W-TAG FOR '#' REPLACE '_'
|
|
PERFORM ADD-XML-LINE
|
|
#W-TAG := 'nif_persona'
|
|
WXMLIN.P-NIF-PERSONA := #W-VALUE
|
|
PERFORM GET-XML-LINE
|
|
END-SUBROUTINE
|
|
DEFINE SUBROUTINE ADD-XML-LINE
|
|
COMPRESS '<' #W-TAG '>' #W-VALUE '</' #W-TAG '>' INTO #W-LINE LEAVING NO
|
|
END-SUBROUTINE
|
|
DEFINE SUBROUTINE GET-XML-LINE
|
|
IGNORE
|
|
END-SUBROUTINE
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("WBIDIR0S.nat", content);
|
|
List<AstNode> payloads = result.nodes().stream()
|
|
.filter(n -> n.type() == NodeType.PAYLOAD_FIELD).toList();
|
|
assertEquals(2, payloads.size());
|
|
|
|
AstNode outbound = payloads.stream().filter(n -> "_COD-USUARIO".equals(n.name())).findFirst().orElseThrow();
|
|
Map<String, String> op = java.util.Objects.requireNonNull(outbound.properties());
|
|
assertEquals("_COD-USUARIO", op.get("tag"), "derived tag: # normalised to _");
|
|
assertEquals("P-COD-USUARIO", op.get("field"));
|
|
assertEquals("REQUEST", op.get("direction"));
|
|
|
|
AstNode inbound = payloads.stream().filter(n -> "nif_persona".equals(n.name())).findFirst().orElseThrow();
|
|
Map<String, String> ip = java.util.Objects.requireNonNull(inbound.properties());
|
|
assertEquals("P-NIF-PERSONA", ip.get("field"), "reverse binding field := valueVar");
|
|
assertEquals("RESPONSE", ip.get("direction"), "GET-XML-LINE is a parse (inbound) sub");
|
|
}
|
|
|
|
@Test
|
|
void moduleWithoutTheXmlIdiomHasNoPayloadFields() {
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
01 #X (A1)
|
|
END-DEFINE
|
|
COMPRESS 'a' 'b' INTO #X
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("PLAIN.nat", content);
|
|
assertFalse(result.nodes().stream().anyMatch(n -> n.type() == NodeType.PAYLOAD_FIELD));
|
|
}
|
|
|
|
@Test
|
|
void commentedAndInlineCommentCallnatsAreNotParsedAsCalls() {
|
|
// Regression: the unanchored CALLNAT / CALLNAT_DYNAMIC patterns used to match inside comments,
|
|
// producing phantom MODULE callees (WGEAGB0S: commented ISINDATE/ADLML02 call-sites; tree-wide
|
|
// "unresolved" WAS/RESULTED/DOES from prose like "* What the callnat does:").
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 #X (A4)
|
|
END-DEFINE
|
|
* CALLNAT 'GHOSTA'
|
|
**SAG CALLNAT 'GHOSTB'
|
|
MOVE 1 TO #X /* CALLNAT 'GHOSTC' inline comment after real code
|
|
* What the callnat does: nothing
|
|
/* Subsequent callnat was issued, last callnat resulted in end-of-data
|
|
CALLNAT 'REALMOD' #X
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("CMT.nat", content);
|
|
AstNode module = findNode(result, NodeType.MODULE, "CMT");
|
|
|
|
// The one real call is recorded...
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "REALMOD", NodeType.MODULE));
|
|
// ...and no commented / prose "callnat" produces a MODULE placeholder.
|
|
for (String ghost : new String[]{"GHOSTA", "GHOSTB", "GHOSTC", "DOES", "WAS", "RESULTED"}) {
|
|
assertFalse(hasNode(result, NodeType.MODULE, ghost),
|
|
"comment token '" + ghost + "' must not be parsed as a CALLNAT target");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The WRITES-edge guard properties recorded for the assignment to {@code targetField}.
|
|
*/
|
|
private static Map<String, String> guardOf(LanguageParser.ParseResult result, String targetField) {
|
|
for (AstEdge edge : result.edges()) {
|
|
if (edge.type() != EdgeType.WRITES || edge.properties() == null
|
|
|| !edge.properties().containsKey("whenValue")) {
|
|
continue;
|
|
}
|
|
for (AstNode node : result.nodes()) {
|
|
if (node.id().equals(edge.targetId()) && node.name().equals(targetField)) {
|
|
return edge.properties();
|
|
}
|
|
}
|
|
}
|
|
throw new AssertionError("no guarded WRITES edge to " + targetField);
|
|
}
|
|
|
|
private static List<String> guardValues(Map<String, String> guard) {
|
|
String encoded = guard.get("whenValues");
|
|
assertNotNull(encoded, "whenValues must be recorded");
|
|
return List.of(encoded.split(NaturalParser.GUARD_VALUE_SEPARATOR));
|
|
}
|
|
|
|
@Test
|
|
void multiValueDecideBranchKeepsEveryAlternativeIncludingBlank() {
|
|
// Item 64: `VALUE 'A', ' '` means the branch also catches a blank guard field. The legacy
|
|
// joined whenValue cannot express that (it drops blanks to avoid a trailing ", "), so the
|
|
// faithful alternatives travel in whenValues. Real shape from WGEAGB0S:530-545.
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 #P-DESC-NAME (A20)
|
|
1 #W-KEY (A8)
|
|
1 #GENAGREE-WOUT-SP (A8)
|
|
1 #ROUTE (A8)
|
|
END-DEFINE
|
|
DECIDE ON FIRST VALUE OF #P-DESC-NAME
|
|
VALUE 'GENAGREE-WOUT-SP', ' '
|
|
#GENAGREE-WOUT-SP := #W-KEY
|
|
VALUE 'A1', 'A2'
|
|
#ROUTE := #W-KEY
|
|
NONE
|
|
IGNORE
|
|
END-DECIDE
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("DISPATCH.nat", content);
|
|
|
|
Map<String, String> blankBranch = guardOf(result, "#GENAGREE-WOUT-SP");
|
|
assertEquals("#P-DESC-NAME", blankBranch.get("whenField"));
|
|
// The lossy field stays as-is for compatibility...
|
|
assertEquals("GENAGREE-WOUT-SP", blankBranch.get("whenValue"));
|
|
// ...while whenValues records that a blank #P-DESC-NAME also routes into this branch.
|
|
assertEquals(List.of("GENAGREE-WOUT-SP", " "), guardValues(blankBranch));
|
|
|
|
// A multi-alternative branch keeps each value separately, rather than only the synthetic
|
|
// joined "A1, A2" that the guarded field never actually equals.
|
|
Map<String, String> multiBranch = guardOf(result, "#ROUTE");
|
|
assertEquals("A1, A2", multiBranch.get("whenValue"));
|
|
assertEquals(List.of("A1", "A2"), guardValues(multiBranch));
|
|
}
|
|
|
|
@Test
|
|
void callnatInsideAStringLiteralIsNotParsedAsACall() {
|
|
// Regression (bug #63): the unanchored CALLNAT / CALLNAT_DYNAMIC patterns matched the keyword
|
|
// inside quoted string literals, so prose in a PRINT/WRITE/assignment fabricated a call. All
|
|
// three lines below are real upms sites. The USIA008N/ISINGEAG ones are the dangerous class:
|
|
// those names are real modules, so the phantom became a real -> real CALLS edge that the
|
|
// item-62 placeholder reaper cannot remove (it only reaps names with no real module).
|
|
String content = """
|
|
DEFINE DATA LOCAL
|
|
1 #X (A4)
|
|
1 #ERR-TYPE (A20)
|
|
END-DEFINE
|
|
PRINT '==> callnat before DREQUFN0'
|
|
WRITE(#MSG) 'NACH CALLNAT ISINGEAG:'
|
|
#ERR-TYPE := 'Callnat USIA008N'
|
|
MOVE 'Start of Callnat' TO #ERR-TYPE
|
|
CALLNAT 'REALMOD' #X
|
|
CALLNAT #DISP
|
|
END
|
|
""";
|
|
LanguageParser.ParseResult result = parser.parse("STRLIT.nat", content);
|
|
AstNode module = findNode(result, NodeType.MODULE, "STRLIT");
|
|
|
|
// A quoted literal never yields a call target...
|
|
for (String ghost : new String[]{"before", "ISINGEAG", "USIA008N"}) {
|
|
assertFalse(hasNode(result, NodeType.MODULE, ghost),
|
|
"'" + ghost + "' inside a string literal must not be parsed as a CALLNAT target");
|
|
}
|
|
// ...while a real static call, whose keyword sits outside the quotes, still resolves...
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "REALMOD", NodeType.MODULE));
|
|
// ...as does genuine dynamic dispatch.
|
|
assertTrue(hasEdge(result, EdgeType.CALLS, module, "#DISP", NodeType.MODULE));
|
|
}
|
|
}
|