Skip to content

Commit b7e9d3b

Browse files
committed
feat: add deterministic R extraction
1 parent 2fa6cd3 commit b7e9d3b

6 files changed

Lines changed: 363 additions & 12 deletions

File tree

graphify/extract.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
4848
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
4949
from graphify.extractors.razor import extract_razor # noqa: F401
50+
from graphify.extractors.r import extract_r # noqa: F401
5051
from graphify.extractors.rust import extract_rust # noqa: F401
5152
from graphify.extractors.sln import extract_sln # noqa: F401
5253
from graphify.extractors.sql import extract_sql # noqa: F401
@@ -4024,6 +4025,7 @@ def add_existing_edge(edge: dict) -> None:
40244025
".sv": extract_verilog,
40254026
".svh": extract_verilog,
40264027
".sql": extract_sql,
4028+
".r": extract_r,
40274029
".md": extract_markdown,
40284030
".mdx": extract_markdown,
40294031
".qmd": extract_markdown,
@@ -4080,7 +4082,7 @@ def add_existing_edge(edge: dict) -> None:
40804082
# routes them to the CODE path via _shebang_interpreter; _get_extractor must
40814083
# honor the same signal or these files are classified as code and then silently
40824084
# dropped by extraction. Only interpreters with a real extractor are mapped —
4083-
# detect's wider set (perl, fish, tcsh, Rscript) stays unmapped and skipped.
4085+
# detect's wider set (perl, fish, tcsh) stays unmapped and skipped.
40844086
_SHEBANG_DISPATCH: dict[str, Any] = {
40854087
"python": extract_python,
40864088
"python2": extract_python,
@@ -4096,6 +4098,7 @@ def add_existing_edge(edge: dict) -> None:
40964098
"lua": extract_lua,
40974099
"php": extract_php,
40984100
"julia": extract_julia,
4101+
"Rscript": extract_r,
40994102
}
41004103

41014104

@@ -4545,8 +4548,8 @@ def extract(
45454548
)
45464549

45474550
# #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST
4548-
# extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently
4549-
# contributes zero nodes. The #1666 warning above deliberately skips these (it
4551+
# extractor wired up silently contributes zero nodes. The #1666 warning above
4552+
# deliberately skips these (it
45504553
# only fires when an extractor exists), so surface them explicitly, grouped by
45514554
# extension, rather than reporting success as if the language were mapped.
45524555
from graphify.detect import CODE_EXTENSIONS as _CODE_EXTS

graphify/extractors/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
2727
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
2828
from graphify.extractors.razor import extract_razor
29+
from graphify.extractors.r import extract_r
2930
from graphify.extractors.rust import extract_rust
3031
from graphify.extractors.sln import extract_sln
3132
from graphify.extractors.sql import extract_sql
@@ -55,6 +56,7 @@
5556
"powershell": extract_powershell,
5657
"powershell_manifest": extract_powershell_manifest,
5758
"razor": extract_razor,
59+
"r": extract_r,
5860
"rust": extract_rust,
5961
"sln": extract_sln,
6062
"sql": extract_sql,

graphify/extractors/r.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""Deterministic R extraction shared with Compass.
2+
3+
R is intentionally source-driven: Graphify does not require an optional R
4+
grammar at runtime, while Compass keeps its grammar bundle static. Keeping the
5+
small structural contract here avoids a classified-as-code file disappearing
6+
from one implementation while preserving the useful R relationships: package
7+
imports, named functions, and direct calls between local functions.
8+
"""
9+
from __future__ import annotations
10+
11+
import re
12+
from pathlib import Path
13+
14+
from graphify.extractors.base import _file_stem, _make_id
15+
16+
_FUNCTION = re.compile(
17+
r"(?m)^[ \t]*([A-Za-z.][A-Za-z0-9._]*)[ \t]*(?:<<-|<-|=)[ \t]*function[ \t]*\("
18+
)
19+
_IMPORT = re.compile(
20+
r"(?m)^[ \t]*(?:library|require|requireNamespace)[ \t]*\("
21+
)
22+
_PACKAGE = re.compile(
23+
r"\s*(?:package\s*=\s*)?[\"']?([A-Za-z][A-Za-z0-9._-]*)"
24+
)
25+
_CALL = re.compile(
26+
r"([A-Za-z.][A-Za-z0-9._]*(?:(?:::|\$)[A-Za-z.][A-Za-z0-9._]*)?)[ \t]*\("
27+
)
28+
_NON_CALLS = frozenset({"function", "if", "for", "while", "repeat", "switch", "return"})
29+
30+
31+
def _mask_non_code(source: str) -> str:
32+
"""Blank comments and strings without changing byte offsets or line numbers."""
33+
out = list(source)
34+
quote: str | None = None
35+
escaped = False
36+
comment = False
37+
for index, char in enumerate(source):
38+
if comment:
39+
if char == "\n":
40+
comment = False
41+
else:
42+
out[index] = " "
43+
continue
44+
if quote is not None:
45+
if char == "\n":
46+
continue
47+
if escaped:
48+
escaped = False
49+
out[index] = " "
50+
elif char == "\\":
51+
escaped = True
52+
out[index] = " "
53+
elif char == quote:
54+
quote = None
55+
else:
56+
out[index] = " "
57+
continue
58+
if char == "#":
59+
comment = True
60+
out[index] = " "
61+
elif char in ("'", '"'):
62+
quote = char
63+
return "".join(out)
64+
65+
66+
def _matching_delimiter(text: str, start: int, opening: str, closing: str) -> int | None:
67+
depth = 1
68+
for index in range(start, len(text)):
69+
if text[index] == opening:
70+
depth += 1
71+
elif text[index] == closing:
72+
depth -= 1
73+
if depth == 0:
74+
return index
75+
return None
76+
77+
78+
def extract_r(path: Path) -> dict:
79+
"""Extract R package imports, named functions, and local direct calls."""
80+
try:
81+
source = path.read_text(encoding="utf-8", errors="replace")
82+
except OSError as exc:
83+
return {"nodes": [], "edges": [], "error": str(exc)}
84+
85+
masked = _mask_non_code(source)
86+
str_path = str(path)
87+
stem = _file_stem(path)
88+
file_nid = _make_id(str_path)
89+
nodes: list[dict] = []
90+
edges: list[dict] = []
91+
raw_calls: list[dict] = []
92+
seen_ids: set[str] = set()
93+
94+
def line_at(offset: int) -> int:
95+
return source.count("\n", 0, offset) + 1
96+
97+
def add_node(nid: str, label: str, line: int) -> None:
98+
if nid not in seen_ids:
99+
seen_ids.add(nid)
100+
nodes.append(
101+
{
102+
"id": nid,
103+
"label": label,
104+
"file_type": "code",
105+
"source_file": str_path,
106+
"source_location": f"L{line}",
107+
}
108+
)
109+
110+
def add_edge(src: str, tgt: str, relation: str, line: int) -> None:
111+
edges.append(
112+
{
113+
"source": src,
114+
"target": tgt,
115+
"relation": relation,
116+
"confidence": "EXTRACTED",
117+
"source_file": str_path,
118+
"source_location": f"L{line}",
119+
"weight": 1.0,
120+
}
121+
)
122+
123+
add_node(file_nid, path.name, 1)
124+
125+
for match in _IMPORT.finditer(masked):
126+
arguments_end = _matching_delimiter(masked, match.end(), "(", ")")
127+
if arguments_end is None:
128+
continue
129+
tail = source[match.end():arguments_end]
130+
package = _PACKAGE.match(tail)
131+
if package:
132+
add_edge(file_nid, _make_id(package.group(1)), "imports", line_at(match.start()))
133+
134+
function_specs: list[tuple[int, int, str]] = []
135+
for match in _FUNCTION.finditer(masked):
136+
parameters_end = _matching_delimiter(masked, match.end(), "(", ")")
137+
if parameters_end is None:
138+
continue
139+
body_start = parameters_end + 1
140+
while body_start < len(masked) and masked[body_start] in " \t\r\n":
141+
body_start += 1
142+
if body_start < len(masked) and masked[body_start] == "{":
143+
end = _matching_delimiter(masked, body_start + 1, "{", "}")
144+
if end is None:
145+
continue
146+
else:
147+
newline = masked.find("\n", body_start)
148+
end = len(masked) - 1 if newline < 0 else newline
149+
function_specs.append((match.start(), end, match.group(1)))
150+
151+
functions: list[dict] = []
152+
for start, end, name in function_specs:
153+
parent = min(
154+
(
155+
function
156+
for function in functions
157+
if function["start"] < start and end <= function["end"]
158+
),
159+
key=lambda function: function["end"] - function["start"],
160+
default=None,
161+
)
162+
parent_id = parent["id"] if parent else None
163+
nid = _make_id(parent_id or stem, name)
164+
line = line_at(start)
165+
add_node(nid, f"{name}()", line)
166+
add_edge(parent_id or file_nid, nid, "contains", line)
167+
functions.append(
168+
{
169+
"start": start,
170+
"end": end,
171+
"id": nid,
172+
"name": name,
173+
"parent": parent_id,
174+
}
175+
)
176+
177+
labels: dict[str, list[dict]] = {}
178+
for function in functions:
179+
labels.setdefault(function["name"], []).append(function)
180+
181+
def call_target(callee: str, caller: dict) -> str | None:
182+
candidates = [
183+
candidate
184+
for candidate in labels.get(callee, [])
185+
if candidate["id"] != caller["id"]
186+
]
187+
children = [
188+
candidate for candidate in candidates if candidate["parent"] == caller["id"]
189+
]
190+
if len(children) == 1:
191+
return children[0]["id"]
192+
same_scope = [
193+
candidate for candidate in candidates if candidate["parent"] == caller["parent"]
194+
]
195+
if len(same_scope) == 1:
196+
return same_scope[0]["id"]
197+
if len(candidates) == 1:
198+
return candidates[0]["id"]
199+
return None
200+
201+
seen_calls: set[tuple[str, str]] = set()
202+
for function in functions:
203+
start = function["start"]
204+
end = function["end"]
205+
caller = function["id"]
206+
body = list(masked[start:end + 1])
207+
for nested in functions:
208+
nested_start = nested["start"]
209+
nested_end = nested["end"]
210+
if start < nested_start and nested_end <= end:
211+
for index in range(nested_start, nested_end + 1):
212+
if body[index - start] != "\n":
213+
body[index - start] = " "
214+
body_text = "".join(body)
215+
for call in _CALL.finditer(body_text):
216+
expression = call.group(1)
217+
callee = re.split(r"::|\$", expression)[-1]
218+
if callee in _NON_CALLS:
219+
continue
220+
absolute = start + call.start(1)
221+
target = call_target(callee, function)
222+
if target:
223+
pair = (caller, target)
224+
if pair not in seen_calls:
225+
seen_calls.add(pair)
226+
add_edge(caller, target, "calls", line_at(absolute))
227+
elif callee:
228+
raw_calls.append(
229+
{
230+
"caller_nid": caller,
231+
"callee": callee,
232+
"is_member_call": "$" in expression,
233+
"source_file": str_path,
234+
"source_location": f"L{line_at(absolute)}",
235+
}
236+
)
237+
238+
clean_edges = [
239+
edge
240+
for edge in edges
241+
if edge["source"] in seen_ids
242+
and (edge["target"] in seen_ids or edge["relation"] == "imports")
243+
]
244+
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}

tests/fixtures/sample.r

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
library(
2+
package = dplyr
3+
)
4+
requireNamespace("jsonlite")
5+
6+
normalize_values <- function(
7+
values,
8+
trim = 0
9+
) {
10+
mean(values, trim = trim)
11+
}
12+
13+
identity_value <<- function(value) value
14+
15+
make_scaler <- function(scale) {
16+
scale_one <- function(value) {
17+
value * scale
18+
}
19+
scale_one(scale)
20+
}
21+
22+
analyze <- function(values) {
23+
normalized <- normalize_values(values)
24+
identity_value(normalized)
25+
stats::median(normalized)
26+
}
27+
28+
# Calls in comments and strings are not executable dependencies:
29+
description <- "normalize_values(fake)
30+
fake <- function(value) {
31+
identity_value(value)
32+
}"

tests/test_extract.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,18 +2268,18 @@ def test_case_insensitive_suffix_filtering(tmp_path):
22682268

22692269

22702270
def test_extract_warns_on_code_files_with_no_ast_extractor(tmp_path, capsys):
2271-
# #1689: .r/.R is in CODE_EXTENSIONS (counted as code) but has no AST extractor,
2272-
# so R files silently contribute nothing. extract() must surface that instead of
2273-
# reporting success as if the language were mapped.
2274-
r1 = tmp_path / "analysis.R"; r1.write_text("f <- function(x) x + 1\n")
2275-
r2 = tmp_path / "helper.r"; r2.write_text("g <- function(y) y * 2\n")
2271+
# #1689: .ejs is in CODE_EXTENSIONS (counted as code) but has no AST extractor,
2272+
# so those files silently contribute nothing. extract() must surface that instead
2273+
# of reporting success as if the language were mapped.
2274+
ejs1 = tmp_path / "analysis.ejs"; ejs1.write_text("<%= result %>\n")
2275+
ejs2 = tmp_path / "helper.ejs"; ejs2.write_text("<%= helper() %>\n")
22762276
py = tmp_path / "main.py"; py.write_text("def main():\n return 1\n")
22772277

2278-
result = extract([r1, r2, py], cache_root=tmp_path)
2278+
result = extract([ejs1, ejs2, py], cache_root=tmp_path)
22792279
err = capsys.readouterr().err
22802280

22812281
assert "no AST extractor" in err
2282-
assert ".r (2)" in err # both R files grouped under the lowercased ext
2282+
assert ".ejs (2)" in err
22832283
assert "#1689" in err
22842284
# the Python file still extracts normally
22852285
labels = [n.get("label") for n in result["nodes"]]
@@ -2330,8 +2330,8 @@ def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsy
23302330
for i in range(100):
23312331
(tmp_path / f"m{i}.py").write_text(f"def f{i}():\n return {i}\n")
23322332
for i in range(5):
2333-
(tmp_path / f"s{i}.r").write_text(f"g{i} <- function(x) x\n") # no extractor
2334-
paths = sorted(tmp_path.glob("*.py")) + sorted(tmp_path.glob("*.r")) # total 105
2333+
(tmp_path / f"s{i}.ejs").write_text("<%= result %>\n") # no extractor
2334+
paths = sorted(tmp_path.glob("*.py")) + sorted(tmp_path.glob("*.ejs")) # total 105
23352335

23362336
extract(paths, cache_root=tmp_path, parallel=False)
23372337
out = capsys.readouterr().out

0 commit comments

Comments
 (0)