diff --git a/graphify/extractors/apex.py b/graphify/extractors/apex.py index 928923a64..9b66d5a72 100644 --- a/graphify/extractors/apex.py +++ b/graphify/extractors/apex.py @@ -73,8 +73,17 @@ def add_edge(src: str, tgt: str, relation: str, line: int, r"^\s*trigger\s+(\w+)\s+on\s+(\w+)\s*\(", _re.IGNORECASE, ) + # An Apex return type is not one bare word: it can be namespace-qualified + # (`Database.QueryLocator`) and can carry generic arguments holding commas + # and spaces (`Map`, `List>`). Apex also + # permits whitespace around the angle brackets themselves (`Map `, + # `List< Account >`), so the type is read as segments joined by the type + # punctuators `<`, `>` and `,`, with whitespace allowed only ADJACENT to one + # of them - never between two bare words. That is what keeps a statement such + # as `insert new Account(...)` from being read as a declaration (#3217). + _TYPE = r"[\w.\[\]]+(?:\s*[<>,]\s*[\w.\[\]]*)*" method_re = _re.compile( - rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?", + rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?{_TYPE}\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?", _re.IGNORECASE, ) annotation_re = _re.compile(r"@(\w+)", _re.IGNORECASE) diff --git a/tests/test_languages.py b/tests/test_languages.py index 46dae524c..c6f6efc29 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3399,6 +3399,56 @@ def test_apex_method_extraction(): assert any("createAccounts" in l for l in labels) assert any("deleteOldAccounts" in l for l in labels) +def test_apex_method_qualified_and_generic_return_types(tmp_path): + source = tmp_path / "Repro.cls" + source.write_text( + "public with sharing class Repro {\n" + " public static String simpleReturn() { return ''; }\n" + " public static Map commaGeneric() { return null; }\n" + " public Database.QueryLocator dottedReturn(Database.BatchableContext bc) { return null; }\n" + " private static List> nestedGeneric() { return null; }\n" + " global Set setReturn() { return null; }\n" + " public String[] arrayReturn() { return null; }\n" + # Apex permits whitespace around the angle brackets themselves + " public Map spaceBeforeAngle() { return null; }\n" + " public List< Account > spacesInside() { return null; }\n" + " public List < Map< String, Id > > roomy() { return null; }\n" + "}\n" + ) + result = extract_apex(source) + labels = _labels(result) + assert ".simpleReturn()" in labels + assert ".commaGeneric()" in labels + assert ".dottedReturn()" in labels + assert ".nestedGeneric()" in labels + assert ".setReturn()" in labels + assert ".arrayReturn()" in labels + assert ".spaceBeforeAngle()" in labels + assert ".spacesInside()" in labels + assert ".roomy()" in labels + +def test_apex_statements_are_not_read_as_methods(tmp_path): + source = tmp_path / "Neg.cls" + source.write_text( + "public class Neg {\n" + " public void real() {\n" + " insert new Account(Name = 'x');\n" + " System.assertEquals(1, ids.size());\n" + " Map m = new Map();\n" + " results.put('a', compute(x));\n" + " this.helper(1, 2);\n" + " Integer a = 1, b = compute();\n" + # a bare `<` / `>` is a comparison, not a generic argument list + " if (a > b) { doIt(x); }\n" + " while (i < list.size()) { next(); }\n" + " String s = (Map) JSON.deserializeUntyped(raw);\n" + " }\n" + "}\n" + ) + result = extract_apex(source) + methods = {l for l in _labels(result) if l.startswith(".")} + assert methods == {".real()"} + def test_apex_contains_and_method_relations(): r = extract_apex(FIXTURES / "sample.cls") relations = _relations(r)