Skip to content
Open
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
13 changes: 13 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from graphify.extractors.markdown import extract_markdown # noqa: F401
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.r import extract_r # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
Expand Down Expand Up @@ -115,6 +116,7 @@
_resolve_export_target,
_resolve_java_type_references,
_resolve_php_type_references,
_resolve_r_bare_calls,
_resolve_js_import_path,
_resolve_js_import_target,
_resolve_js_module_path,
Expand Down Expand Up @@ -3894,6 +3896,7 @@ def add_existing_edge(edge: dict) -> None:
".m": extract_objc,
".mm": extract_objc,
".jl": extract_julia,
".r": extract_r,
".f": extract_fortran,
".F": extract_fortran,
".f90": extract_fortran,
Expand Down Expand Up @@ -4787,6 +4790,16 @@ def _learn(e: dict) -> None:
import logging
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)

# Cross-file R call resolution (bare-name lookup — R has no import syntax
# that names its target file, so this isn't an "imports" resolver like the
# blocks above; see _resolve_r_bare_calls's docstring in resolution.py).
if any(p.suffix.lower() == ".r" for p in paths):
try:
_resolve_r_bare_calls(all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("R bare-call resolution failed, skipping: %s", exc)

# Cross-file Java import resolution
java_paths = [p for p in paths if p.suffix == ".java"]
if java_paths:
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from graphify.extractors.pascal import extract_pascal
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.r import extract_r
from graphify.extractors.razor import extract_razor
from graphify.extractors.rust import extract_rust
from graphify.extractors.sln import extract_sln
Expand Down Expand Up @@ -54,6 +55,7 @@
"pascal": extract_pascal,
"powershell": extract_powershell,
"powershell_manifest": extract_powershell_manifest,
"r": extract_r,
"razor": extract_razor,
"rust": extract_rust,
"sln": extract_sln,
Expand Down
28 changes: 28 additions & 0 deletions graphify/extractors/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# DO NOT import from graphify.extract here — direction is extract.py → extractors/ only.
from __future__ import annotations

import re
from pathlib import Path

from graphify.ids import make_id
Expand Down Expand Up @@ -31,6 +32,33 @@
"callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir",
})

# Languages that allow quoting an operator as an identifier (R's
# `%||%` <- function(a, b) ...) need it turned into an id-safe name before
# `make_id` sees it. `make_id`/`normalize_id` replace every non-word run with
# a single underscore, so an all-symbol name has no word characters left and
# collapses to the empty string — `make_id(stem, "%||%")` then equals
# `make_id(stem)`, i.e. the FILE node's own id, producing a same-file-vs-
# operator self-loop instead of a distinct node. Shared here (not per-
# language) because both the extractor that defines the operator and the
# cross-file resolver that looks it up by name must agree on the same id.
_OP_CHAR_NAMES: dict[str, str] = {
"%": "pct", "|": "pipe", "&": "amp", "/": "slash", "+": "plus",
"-": "minus", "*": "star", "^": "caret", "<": "lt", ">": "gt",
"=": "eq", "!": "bang", "~": "tilde", "?": "qmark", ":": "colon",
"@": "at", "$": "dollar",
}


def _symbol_safe_name(name: str) -> str:
"""ASCII-transliterate an all-symbol identifier for id-building.

A name with at least one word character is returned unchanged — normal
identifiers don't need this and `make_id` handles them correctly already.
"""
if re.sub(r"\W+", "", name, flags=re.UNICODE):
return name
return "".join(_OP_CHAR_NAMES.get(c, "x") for c in name) or "op"


def _make_id(*parts: str) -> str:
return make_id(*parts)
Expand Down
Loading