Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.r import extract_r # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.sql import extract_sql # noqa: F401
Expand Down Expand Up @@ -3904,6 +3905,7 @@ def add_existing_edge(edge: dict) -> None:
".sv": extract_verilog,
".svh": extract_verilog,
".sql": extract_sql,
".r": extract_r,
".md": extract_markdown,
".mdx": extract_markdown,
".qmd": extract_markdown,
Expand Down Expand Up @@ -3960,7 +3962,7 @@ def add_existing_edge(edge: dict) -> None:
# routes them to the CODE path via _shebang_interpreter; _get_extractor must
# honor the same signal or these files are classified as code and then silently
# dropped by extraction. Only interpreters with a real extractor are mapped —
# detect's wider set (perl, fish, tcsh, Rscript) stays unmapped and skipped.
# detect's wider set (perl, fish, tcsh) stays unmapped and skipped.
_SHEBANG_DISPATCH: dict[str, Any] = {
"python": extract_python,
"python2": extract_python,
Expand All @@ -3976,6 +3978,7 @@ def add_existing_edge(edge: dict) -> None:
"lua": extract_lua,
"php": extract_php,
"julia": extract_julia,
"Rscript": extract_r,
}


Expand Down Expand Up @@ -4425,8 +4428,8 @@ def extract(
)

# #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST
# extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently
# contributes zero nodes. The #1666 warning above deliberately skips these (it
# extractor wired up silently contributes zero nodes. The #1666 warning above
# deliberately skips these (it
# only fires when an extractor exists), so surface them explicitly, grouped by
# extension, rather than reporting success as if the language were mapped.
from graphify.detect import CODE_EXTENSIONS as _CODE_EXTS
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
from graphify.extractors.razor import extract_razor
from graphify.extractors.r import extract_r
from graphify.extractors.rust import extract_rust
from graphify.extractors.sln import extract_sln
from graphify.extractors.sql import extract_sql
Expand Down Expand Up @@ -55,6 +56,7 @@
"powershell": extract_powershell,
"powershell_manifest": extract_powershell_manifest,
"razor": extract_razor,
"r": extract_r,
"rust": extract_rust,
"sln": extract_sln,
"sql": extract_sql,
Expand Down
218 changes: 218 additions & 0 deletions graphify/extractors/r.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Deterministic R extraction for Graphify.

R is intentionally source-driven so Graphify does not require an optional R
grammar at runtime. The extractor preserves useful R relationships for package
imports, named functions, containment, and direct calls between local functions.
"""
from __future__ import annotations

import re
from pathlib import Path

from graphify.extractors.base import _file_stem, _make_id

_FUNCTION = re.compile(
r"(?m)^[ \t]*([A-Za-z.][A-Za-z0-9._]*)[ \t]*(?:<<-|<-|=)[ \t]*function[ \t]*\("
)
_IMPORT = re.compile(
r"(?m)^[ \t]*(?:library|require|requireNamespace)[ \t]*\("
)
_PACKAGE = re.compile(
r"\s*(?:package\s*=\s*)?[\"']?([A-Za-z][A-Za-z0-9._-]*)"
)
_CALL = re.compile(
r"([A-Za-z.][A-Za-z0-9._]*(?:(?:::|\$)[A-Za-z.][A-Za-z0-9._]*)?)[ \t]*\("
)
_NON_CALLS = frozenset({"function", "if", "for", "while", "repeat", "switch", "return"})


def _mask_non_code(source: str) -> str:
"""Blank comments and strings without changing byte offsets or line numbers."""
out = list(source)
quote: str | None = None
escaped = False
comment = False
for index, char in enumerate(source):
if comment:
if char == "\n":
comment = False
else:
out[index] = " "
continue
if quote is not None:
if char == "\n":
continue
if escaped:
escaped = False
out[index] = " "
elif char == "\\":
escaped = True
out[index] = " "
elif char == quote:
quote = None
else:
out[index] = " "
continue
if char == "#":
comment = True
out[index] = " "
elif char in ("'", '"'):
quote = char
return "".join(out)


def _matching_delimiter(text: str, start: int, opening: str, closing: str) -> int | None:
depth = 1
for index in range(start, len(text)):
if text[index] == opening:
depth += 1
elif text[index] == closing:
depth -= 1
if depth == 0:
return index
return None


def extract_r(path: Path) -> dict:
"""Extract R package imports, named functions, and local direct calls."""
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"nodes": [], "edges": [], "error": str(exc)}

masked = _mask_non_code(source)
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str_path)
nodes: list[dict] = []
edges: list[dict] = []
raw_calls: list[dict] = []
seen_ids: set[str] = set()

def line_at(offset: int) -> int:
return source.count("\n", 0, offset) + 1

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) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})

add_node(file_nid, path.name, 1)

for match in _IMPORT.finditer(masked):
arguments_end = _matching_delimiter(masked, match.end(), "(", ")")
if arguments_end is None:
continue
tail = source[match.end():arguments_end]
package = _PACKAGE.match(tail)
if package:
add_edge(file_nid, _make_id(package.group(1)), "imports", line_at(match.start()))

function_specs: list[tuple[int, int, str]] = []
for match in _FUNCTION.finditer(masked):
parameters_end = _matching_delimiter(masked, match.end(), "(", ")")
if parameters_end is None:
continue
body_start = parameters_end + 1
while body_start < len(masked) and masked[body_start] in " \t\r\n":
body_start += 1
if body_start < len(masked) and masked[body_start] == "{":
end = _matching_delimiter(masked, body_start + 1, "{", "}")
if end is None:
continue
else:
newline = masked.find("\n", body_start)
end = len(masked) - 1 if newline < 0 else newline
function_specs.append((match.start(), end, match.group(1)))

functions: list[dict] = []
for start, end, name in function_specs:
parent = min(
(
function
for function in functions
if function["start"] < start and end <= function["end"]
),
key=lambda function: function["end"] - function["start"],
default=None,
)
parent_id = parent["id"] if parent else None
nid = _make_id(parent_id or stem, name)
line = line_at(start)
add_node(nid, f"{name}()", line)
add_edge(parent_id or file_nid, nid, "contains", line)
functions.append({
"start": start,
"end": end,
"id": nid,
"name": name,
"parent": parent_id,
})

labels: dict[str, list[dict]] = {}
for function in functions:
labels.setdefault(function["name"], []).append(function)

def call_target(callee: str, caller: dict) -> str | None:
candidates = [
candidate
for candidate in labels.get(callee, [])
if candidate["id"] != caller["id"]
]
children = [
candidate for candidate in candidates
if candidate["parent"] == caller["id"]
]
if len(children) == 1:
return children[0]["id"]
same_scope = [
candidate for candidate in candidates
if candidate["parent"] == caller["parent"]
]
if len(same_scope) == 1:
return same_scope[0]["id"]
if len(candidates) == 1:
return candidates[0]["id"]
return None

seen_calls: set[tuple[str, str]] = set()
for function in functions:
start = function["start"]
end = function["end"]
caller = function["id"]
body = list(masked[start:end + 1])
for nested in functions:
nested_start = nested["start"]
nested_end = nested["end"]
if start < nested_start and nested_end <= end:
for index in range(nested_start, nested_end + 1):
if body[index - start] != "\n":
body[index - start] = " "
body_text = "".join(body)
for call in _CALL.finditer(body_text):
expression = call.group(1)
callee = re.split(r"::|\$", expression)[-1]
if callee in _NON_CALLS:
continue
absolute = start + call.start(1)
target = call_target(callee, function)
if target:
pair = (caller, target)
if pair not in seen_calls:
seen_calls.add(pair)
add_edge(caller, target, "calls", line_at(absolute))
elif callee:
raw_calls.append({"caller_nid": caller, "callee": callee,
"is_member_call": "$" in expression,
"source_file": str_path,
"source_location": f"L{line_at(absolute)}"})

clean_edges = [edge for edge in edges if edge["source"] in seen_ids and
(edge["target"] in seen_ids or edge["relation"] == "imports")]
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
32 changes: 32 additions & 0 deletions tests/fixtures/sample.r
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
library(
package = dplyr
)
requireNamespace("jsonlite")

normalize_values <- function(
values,
trim = 0
) {
mean(values, trim = trim)
}

identity_value <<- function(value) value

make_scaler <- function(scale) {
scale_one <- function(value) {
value * scale
}
scale_one(scale)
}

analyze <- function(values) {
normalized <- normalize_values(values)
identity_value(normalized)
stats::median(normalized)
}

# Calls in comments and strings are not executable dependencies:
description <- "normalize_values(fake)
fake <- function(value) {
identity_value(value)
}"