From 299af15383c4c569528f8e0d507f99e0f672d8c7 Mon Sep 17 00:00:00 2001 From: Rajeev Mishra <20rajeevmishra@gmail.com> Date: Sat, 6 Jun 2026 21:22:25 +0200 Subject: [PATCH] feat(extract): add Salesforce Apex extractor (.cls, .trigger) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds regex-based extraction support for Salesforce Apex — a Java-like language used for server-side logic on the Salesforce platform. No tree-sitter-apex grammar exists on PyPI, so this follows the same regex pattern used for Pascal, Razor, and .NET project files. Extracts from .cls files: - Classes (public/global/private, with/without sharing, abstract, virtual) - Inner interfaces and enums - Methods at all access levels, including annotated methods (@AuraEnabled, @future, @InvocableMethod, @isTest) - Inheritance: extends → `extends` edge, implements → `implements` edge - SOQL queries: [SELECT ... FROM SObject] → `uses` edge to the SObject - DML operations: insert/update/delete/upsert/merge/undelete → `uses` edges Extracts from .trigger files: - Trigger declaration name + the SObject it fires on → `uses` edge Changes: - graphify/extract.py: add extract_apex(); register .cls/.trigger in _DISPATCH - graphify/detect.py: add .cls and .trigger to CODE_EXTENSIONS - tests/fixtures/sample.cls: Apex class fixture covering all extracted constructs - tests/fixtures/sample.trigger: Apex trigger fixture on Account SObject - tests/test_languages.py: 12 new tests (class, enum, interface, method, contains/method relations, SOQL uses edge, DML uses edge, file node, trigger extraction, trigger SObject link, missing-file safety, no-dangling-edges invariant) - README.md: add .cls and .trigger to the supported extensions table All 12 tests pass. No regressions in existing suite. --- README.md | 2 +- graphify/detect.py | 2 +- graphify/extract.py | 201 ++++++++++++++++++++++++++++++++++ tests/fixtures/sample.cls | 55 ++++++++++ tests/fixtures/sample.trigger | 8 ++ tests/test_languages.py | 77 ++++++++++++- 6 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/sample.cls create mode 100644 tests/fixtures/sample.trigger diff --git a/README.md b/README.md index e45a70a81..2712c1dca 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (28 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .csproj .fsproj .vbproj .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`) | +| Code (28 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .csproj .fsproj .vbproj .razor .cshtml .cls .trigger` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | | Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` | diff --git a/graphify/detect.py b/graphify/detect.py index e15a413f6..cb6964c91 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -25,7 +25,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 9cdf36fe9..8e727c525 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4041,6 +4041,205 @@ def extract_csharp(path: Path) -> dict: return _extract_generic(path, _CSHARP_CONFIG) +def extract_apex(path: Path) -> dict: + """Extract classes, interfaces, enums, methods, and Salesforce constructs from + Apex .cls and .trigger files using regex (no tree-sitter grammar on PyPI).""" + import re as _re + try: + source = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"nodes": [], "edges": []} + + str_path = str(path) + stem = _file_stem(path) + file_nid = _make_id(str_path) + + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED") -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + add_node(file_nid, path.name, 1) + + lines = source.splitlines() + + _ACCESS = r"(?:public|private|protected|global|webService)?" + _SHARING = r"(?:\s+(?:with|without|inherited)\s+sharing)?" + _MOD = r"(?:\s+(?:abstract|virtual|override|static|final|transient|testMethod))?" + _ANNOTATION = r"(?:\s*@\w+(?:\s*\([^)]*\))?\s*)*" + + cls_re = _re.compile( + rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*class\s+(\w+)" + rf"(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w,\s]+))?\s*\{{?", + _re.IGNORECASE, + ) + iface_re = _re.compile( + rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*interface\s+(\w+)" + rf"(?:\s+extends\s+([\w,\s]+))?\s*\{{?", + _re.IGNORECASE, + ) + enum_re = _re.compile( + rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*enum\s+(\w+)\s*\{{?", + _re.IGNORECASE, + ) + trigger_re = _re.compile( + r"^\s*trigger\s+(\w+)\s+on\s+(\w+)\s*\(", + _re.IGNORECASE, + ) + method_re = _re.compile( + rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?", + _re.IGNORECASE, + ) + annotation_re = _re.compile(r"@(\w+)", _re.IGNORECASE) + soql_re = _re.compile(r"\[\s*SELECT\b[^\]]+FROM\s+(\w+)", _re.IGNORECASE) + dml_re = _re.compile(r"\b(insert|update|delete|upsert|merge|undelete)\s+\w", _re.IGNORECASE) + + _CONTROL_FLOW = frozenset({ + "if", "else", "for", "while", "do", "switch", "try", "catch", + "finally", "return", "throw", "new", "void", "null", + "true", "false", "this", "super", "class", "interface", "enum", + "trigger", "on", + }) + + current_class_nid: str | None = None + pending_annotations: list[str] = [] + + for lineno, line_text in enumerate(lines, start=1): + stripped = line_text.strip() + + if stripped.startswith("@"): + for m in annotation_re.finditer(stripped): + pending_annotations.append(m.group(1).lower()) + continue + + tm = trigger_re.match(stripped) + if tm: + trig_name, sobject = tm.group(1), tm.group(2) + trig_nid = _make_id(stem, trig_name) + add_node(trig_nid, trig_name, lineno) + add_edge(file_nid, trig_nid, "contains", lineno) + sob_nid = _make_id(sobject) + if sob_nid not in seen_ids: + add_node(sob_nid, sobject, lineno) + add_edge(trig_nid, sob_nid, "uses", lineno, confidence="INFERRED") + current_class_nid = trig_nid + pending_annotations = [] + continue + + cm = cls_re.match(stripped) + if cm: + class_name = cm.group(1) + if class_name.lower() in _CONTROL_FLOW: + pending_annotations = [] + continue + class_nid = _make_id(stem, class_name) + add_node(class_nid, class_name, lineno) + add_edge(file_nid, class_nid, "contains", lineno) + if cm.group(2): + base = cm.group(2).strip() + base_nid = _make_id(stem, base) + if base_nid not in seen_ids: + base_nid = _make_id(base) + if base_nid not in seen_ids: + add_node(base_nid, base, lineno) + add_edge(class_nid, base_nid, "extends", lineno, confidence="INFERRED") + if cm.group(3): + for iface in cm.group(3).split(","): + iface = iface.strip() + if iface: + iface_nid = _make_id(stem, iface) + if iface_nid not in seen_ids: + iface_nid = _make_id(iface) + if iface_nid not in seen_ids: + add_node(iface_nid, iface, lineno) + add_edge(class_nid, iface_nid, "implements", lineno, confidence="INFERRED") + current_class_nid = class_nid + pending_annotations = [] + continue + + im = iface_re.match(stripped) + if im: + iface_name = im.group(1) + if iface_name.lower() in _CONTROL_FLOW: + pending_annotations = [] + continue + iface_nid = _make_id(stem, iface_name) + add_node(iface_nid, iface_name, lineno) + add_edge(file_nid if current_class_nid is None else current_class_nid, + iface_nid, "contains", lineno) + pending_annotations = [] + continue + + em = enum_re.match(stripped) + if em: + enum_name = em.group(1) + if enum_name.lower() in _CONTROL_FLOW: + pending_annotations = [] + continue + enum_nid = _make_id(stem, enum_name) + add_node(enum_nid, enum_name, lineno) + add_edge(file_nid if current_class_nid is None else current_class_nid, + enum_nid, "contains", lineno) + pending_annotations = [] + continue + + if current_class_nid is not None: + mm = method_re.match(stripped) + if mm: + method_name = mm.group(1) + if method_name.lower() not in _CONTROL_FLOW: + method_nid = _make_id(current_class_nid, method_name) + method_label = f".{method_name}()" + add_node(method_nid, method_label, lineno) + add_edge(current_class_nid, method_nid, "method", lineno) + if "auraenabled" in pending_annotations or "invocablemethod" in pending_annotations: + add_edge(file_nid, method_nid, "contains", lineno, confidence="INFERRED") + pending_annotations = [] + continue + + pending_annotations = [] + + for sm in soql_re.finditer(line_text): + sobject = sm.group(1) + sob_nid = _make_id(sobject) + if sob_nid not in seen_ids: + add_node(sob_nid, sobject, lineno) + src = current_class_nid or file_nid + add_edge(src, sob_nid, "uses", lineno, confidence="INFERRED") + + for dm in dml_re.finditer(line_text): + dml_op = dm.group(1).lower() + dml_nid = _make_id(f"dml_{dml_op}") + if dml_nid not in seen_ids: + add_node(dml_nid, dml_op, lineno) + src = current_class_nid or file_nid + add_edge(src, dml_nid, "uses", lineno, confidence="INFERRED") + + return {"nodes": nodes, "edges": edges} + + def extract_kotlin(path: Path) -> dict: """Extract classes, objects, functions, and imports from a .kt/.kts file.""" return _extract_generic(path, _KOTLIN_CONFIG) @@ -10825,6 +11024,8 @@ def _body_of(block): ".vbproj": extract_csproj, ".razor": extract_razor, ".cshtml": extract_razor, + ".cls": extract_apex, + ".trigger": extract_apex, } diff --git a/tests/fixtures/sample.cls b/tests/fixtures/sample.cls new file mode 100644 index 000000000..156ddc7e2 --- /dev/null +++ b/tests/fixtures/sample.cls @@ -0,0 +1,55 @@ +public with sharing class AccountService { + + private static final String DEFAULT_TYPE = 'Customer'; + + public interface Notifiable { + void notify(String message); + } + + public enum AccountStatus { ACTIVE, INACTIVE, PENDING } + + @AuraEnabled + public static List getAccounts(String accountType) { + return [SELECT Id, Name, Type FROM Account WHERE Type = :accountType]; + } + + @future + public static void updateAccountsAsync(List accountIds) { + List accounts = [SELECT Id FROM Account WHERE Id IN :accountIds]; + for (Account acc : accounts) { + acc.Type = DEFAULT_TYPE; + } + update accounts; + } + + @InvocableMethod(label='Create Account' description='Creates a new Account') + public static List createAccounts(List names) { + List toInsert = new List(); + for (String n : names) { + toInsert.add(new Account(Name = n)); + } + insert toInsert; + List ids = new List(); + for (Account a : toInsert) { + ids.add(a.Id); + } + return ids; + } + + public static void deleteOldAccounts(Date cutoff) { + List old = [SELECT Id FROM Account WHERE CreatedDate < :cutoff]; + delete old; + } + + @isTest + static void testGetAccounts() { + List result = getAccounts('Customer'); + System.assertNotEquals(null, result); + } + + @isTest + static void testCreateAccounts() { + List ids = createAccounts(new List{'Test'}); + System.assertEquals(1, ids.size()); + } +} diff --git a/tests/fixtures/sample.trigger b/tests/fixtures/sample.trigger new file mode 100644 index 000000000..d24a0f140 --- /dev/null +++ b/tests/fixtures/sample.trigger @@ -0,0 +1,8 @@ +trigger AccountTrigger on Account (before insert, before update, after insert, after update) { + if (Trigger.isBefore) { + AccountService.validateAccounts(Trigger.new); + } + if (Trigger.isAfter && Trigger.isInsert) { + AccountService.sendWelcomeNotifications(Trigger.new); + } +} diff --git a/tests/test_languages.py b/tests/test_languages.py index dab5ce563..c8e2acca7 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -8,7 +8,7 @@ extract_swift, extract_go, extract_julia, extract_js, extract_fortran, extract_groovy, extract_sln, extract_csproj, extract_razor, extract_dm, extract_dmi, extract_dmm, extract_dmf, - extract_powershell, + extract_powershell, extract_apex, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -1553,3 +1553,78 @@ def test_razor_no_dangling_edges(): node_ids = {n["id"] for n in r["nodes"]} for e in r["edges"]: assert e["source"] in node_ids + + +# ---------------Salesforce Apex (.cls / .trigger)---------------------- + +def test_apex_class_extraction(): + r = extract_apex(FIXTURES / "sample.cls") + labels = _labels(r) + assert "AccountService" in labels + +def test_apex_enum_extraction(): + r = extract_apex(FIXTURES / "sample.cls") + labels = _labels(r) + assert "AccountStatus" in labels + +def test_apex_interface_extraction(): + r = extract_apex(FIXTURES / "sample.cls") + labels = _labels(r) + assert "Notifiable" in labels + +def test_apex_method_extraction(): + r = extract_apex(FIXTURES / "sample.cls") + labels = _labels(r) + assert any("getAccounts" in l for l in labels) + assert any("updateAccountsAsync" in l for l in labels) + assert any("createAccounts" in l for l in labels) + assert any("deleteOldAccounts" in l for l in labels) + +def test_apex_contains_and_method_relations(): + r = extract_apex(FIXTURES / "sample.cls") + relations = _relations(r) + assert "contains" in relations + assert "method" in relations + +def test_apex_soql_uses_edge(): + r = extract_apex(FIXTURES / "sample.cls") + relations = _relations(r) + assert "uses" in relations + labels = _labels(r) + assert "Account" in labels + +def test_apex_dml_uses_edge(): + r = extract_apex(FIXTURES / "sample.cls") + dml_labels = {n["label"] for n in r["nodes"] if n["label"] in ("insert", "update", "delete", "upsert")} + assert len(dml_labels) > 0 + +def test_apex_file_node_present(): + r = extract_apex(FIXTURES / "sample.cls") + labels = _labels(r) + assert "sample.cls" in labels + +def test_apex_trigger_extraction(): + r = extract_apex(FIXTURES / "sample.trigger") + labels = _labels(r) + assert "sample.trigger" in labels + assert "AccountTrigger" in labels + +def test_apex_trigger_uses_sobject(): + r = extract_apex(FIXTURES / "sample.trigger") + relations = _relations(r) + assert "uses" in relations + labels = _labels(r) + assert "Account" in labels + +def test_apex_missing_file_returns_empty(): + r = extract_apex(Path("nonexistent.cls")) + assert r["nodes"] == [] + assert r["edges"] == [] + +def test_apex_no_dangling_edges(): + for fixture in ("sample.cls", "sample.trigger"): + r = extract_apex(FIXTURES / fixture) + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids, f"dangling source in {fixture}: {e}" + assert e["target"] in node_ids, f"dangling target in {fixture}: {e}"