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
1 change: 1 addition & 0 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
**{e: "dotnet" for e in (".cs",)},
**{e: "php" for e in (".php",)},
**{e: "r" for e in (".r",)},
**{e: "qml" for e in (".qml",)},
}


Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class FileType(str, Enum):

_MANIFEST_PATH = str(out_path("manifest.json"))

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.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', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.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', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.qml'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
114 changes: 113 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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.qml import extract_qml # 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 @@ -1622,9 +1623,20 @@ def extract_c(path: Path) -> dict:
return _extract_generic(path, _C_CONFIG)


# Bare Q_OBJECT/Q_GADGET (no semicolon) fuse with the next declaration under
# tree-sitter-cpp error recovery, mangling a following signals:/slots: section.
# Insert a semicolon so those sections parse as normal field_declarations (#1716).
_CPP_BARE_QOBJECT_MACRO_RE = re.compile(rb"\b(Q_OBJECT|Q_GADGET)\b(?!\s*;)")


def extract_cpp(path: Path) -> dict:
"""Extract functions, classes, and includes from a .cpp/.cc/.cxx/.hpp file."""
return _extract_generic(path, _CPP_CONFIG)
try:
source = path.read_bytes()
except OSError as e:
return {"nodes": [], "edges": [], "error": str(e)}
patched = _CPP_BARE_QOBJECT_MACRO_RE.sub(rb"\1;", source)
return _extract_generic(path, _CPP_CONFIG, source_override=patched)


def extract_ruby(path: Path) -> dict:
Expand Down Expand Up @@ -1789,6 +1801,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool:
".dart": "dart",
".sh": "shell", ".bash": "shell",
".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
".qml": "qml",
}


Expand Down Expand Up @@ -2603,6 +2616,91 @@ def _key(label: str) -> str:
})


def _resolve_cpp_qml_aliases(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Repoint QML type stubs onto C++ classes via QML_NAMED_ELEMENT / qmlRegisterType (#1716).

Same-name links are already handled by ``_rewire_unique_stub_nodes``. This
pass covers aliases only, and only when the alias maps to exactly one class
and one stub (god-node guard). Runs after rewire + id-disambiguation.
"""
alias_to_class_nids: dict[str, set[str]] = {}
label_lookups: list[tuple[str, str]] = []
for result in per_file:
for entry in result.get("cpp_qml_aliases", []):
alias = entry.get("alias")
if not alias:
continue
class_nid = entry.get("class_nid")
if class_nid:
alias_to_class_nids.setdefault(alias, set()).add(class_nid)
continue
class_label = entry.get("class_label")
if class_label:
label_lookups.append((alias, class_label))
if not alias_to_class_nids and not label_lookups:
return

node_by_id = {n.get("id"): n for n in all_nodes if n.get("id")}
valid_alias_to_class: dict[str, set[str]] = {
alias: real
for alias, nids in alias_to_class_nids.items()
if (real := {nid for nid in nids if nid in node_by_id})
}

if label_lookups:
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
class_nids_by_label: dict[str, set[str]] = {}
for n in all_nodes:
if (n.get("source_file") and n.get("id") in contained
and _is_type_like_definition(n)):
class_nids_by_label.setdefault(n.get("label", ""), set()).add(n["id"])
for alias, class_label in label_lookups:
candidates = class_nids_by_label.get(class_label)
if candidates:
valid_alias_to_class.setdefault(alias, set()).update(candidates)

# Only QML type stubs carry origin_file; import-module nodes are sourceless
# too but must not be remapped if an alias collides with their label.
stub_ids_by_label: dict[str, set[str]] = {}
for n in all_nodes:
if n.get("source_file") or not n.get("origin_file"):
continue
label, nid = n.get("label"), n.get("id")
if isinstance(label, str) and isinstance(nid, str):
stub_ids_by_label.setdefault(label, set()).add(nid)

remap: dict[str, str] = {}
for alias, class_nids in valid_alias_to_class.items():
if len(class_nids) != 1:
continue
stub_ids = stub_ids_by_label.get(alias)
if not stub_ids or len(stub_ids) != 1:
continue
target_nid = next(iter(class_nids))
stub_id = next(iter(stub_ids))
if stub_id != target_nid:
remap[stub_id] = target_nid

if not remap:
return

for edge in all_edges:
src, tgt = edge.get("source"), edge.get("target")
if src in remap:
edge["source"] = remap[src]
if tgt in remap:
edge["target"] = remap[tgt]

referenced = {x for e in all_edges for x in (e.get("source"), e.get("target"))}
drop_ids = {stub_id for stub_id in remap if stub_id not in referenced}
if drop_ids:
all_nodes[:] = [n for n in all_nodes if n.get("id") not in drop_ids]


# Register the cross-file, language-specific member-call resolvers into the shared
# registry (framework lives in graphify.resolver_registry). A new language plugs in
# by adding one register() call below — no edits to extract()'s body. Order
Expand Down Expand Up @@ -2638,6 +2736,14 @@ def _key(label: str) -> str:
_resolve_objc_member_calls,
)
)
# C++/QML alias bridge (#1716).
register_language_resolver(
LanguageResolver(
"cpp_qml_aliases",
frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}),
_resolve_cpp_qml_aliases,
)
)
# C# receiver-typed member-call resolution (#1609): `field/param/local.Method()`
# bound to the receiver's declared type instead of a bare same-named match.
register_language_resolver(
Expand Down Expand Up @@ -3739,6 +3845,7 @@ def add_existing_edge(edge: dict) -> None:
".cshtml": extract_razor,
".cls": extract_apex,
".trigger": extract_apex,
".qml": extract_qml,
}


Expand Down Expand Up @@ -4325,6 +4432,11 @@ def extract(
cn = rc.get("caller_nid")
if cn in sym_remap:
rc["caller_nid"] = sym_remap[cn]
for result in per_file:
for alias_entry in result.get("cpp_qml_aliases", []):
cn = alias_entry.get("class_nid")
if cn in sym_remap:
alias_entry["class_nid"] = sym_remap[cn]

_merge_swift_extensions(per_file, all_nodes, all_edges)
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
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.qml import extract_qml
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,
"qml": extract_qml,
"razor": extract_razor,
"rust": extract_rust,
"sln": extract_sln,
Expand Down
Loading