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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
|------|-----------|
| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .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 .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
| Twig templates | `.twig` (regex-based; `extends`/`include`/`embed`/`import`/`use` inheritance, `{% block %}` definitions, and `path()`/`url()` route references linking templates to their controllers) |
| 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 |
| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub |
Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class FileType(str, Enum):

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

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.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', '.twig', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.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'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
3 changes: 3 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from graphify.extractors.apex import extract_apex # noqa: F401
from graphify.extractors.bash import extract_bash # noqa: F401
from graphify.extractors.blade import extract_blade # noqa: F401
from graphify.extractors.twig import extract_twig # noqa: F401
from graphify.extractors.csharp import (
_resolve_cross_file_csharp_imports,
_resolve_csharp_type_references,
Expand Down Expand Up @@ -4056,6 +4057,8 @@ def _get_extractor(path: Path) -> Any | None:
"""Return the correct extractor function for a file, or None if unsupported."""
if path.name.lower().endswith(".blade.php"):
return extract_blade
if path.name.lower().endswith(".twig"):
return extract_twig
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
# by filename before generic .json dispatch so they get MCP-aware nodes
# (servers, commands, packages, env vars) instead of opaque JSON keys.
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from graphify.extractors.apex import extract_apex
from graphify.extractors.bash import extract_bash
from graphify.extractors.blade import extract_blade
from graphify.extractors.twig import extract_twig
from graphify.extractors.dart import extract_dart
from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm
from graphify.extractors.elixir import extract_elixir
Expand All @@ -37,6 +38,7 @@
"apex": extract_apex,
"bash": extract_bash,
"blade": extract_blade,
"twig": extract_twig,
"dart": extract_dart,
"delphi_form": extract_delphi_form,
"dm": extract_dm,
Expand Down
110 changes: 110 additions & 0 deletions graphify/extractors/twig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Twig template extractor (Symfony, Drupal, Craft CMS, Grav)."""
from __future__ import annotations


import re
from pathlib import Path

from graphify.extractors.base import _make_id


# Tag forms that name another template: {% extends "base.html.twig" %},
# {% include %}, {% embed %}, {% import %}, {% from %}, {% use %}.
_TAG_RE = re.compile(
r"{%-?\s*(extends|include|embed|import|from|use)\s+['\"]([^'\"]+)['\"]"
)
# {% block content %} / {%- block content -%}
_BLOCK_RE = re.compile(r"{%-?\s*block\s+([A-Za-z_]\w*)")
# Expression-level calls, matched only inside {{ ... }} / {% ... %} (see below).
_FN_INCLUDE_RE = re.compile(r"\binclude\(\s*['\"]([^'\"]+)['\"]")
_ROUTE_RE = re.compile(r"\b(?:path|url)\(\s*['\"]([^'\"]+)['\"]")
# Twig expression/statement regions. Restricting the function-call scans to
# these avoids matching a literal `path(` or `include(` sitting in inline
# <script>/<style> content, which templates carry a lot of.
_EXPR_RE = re.compile(r"{[{%].*?[}%]}", re.DOTALL)

_RELATION_BY_TAG = {
"extends": "extends",
"include": "includes",
"embed": "embeds",
"import": "imports",
"from": "imports",
"use": "uses",
}


def _resolve(path: Path, ref: str) -> str:
"""Map a Twig reference to the real file it names, when that file exists.

Twig resolves names against the loader's root rather than the current file,
and the conventional root is a directory literally named ``templates`` in
Symfony, Drupal, Craft and Grav alike. Walking up to that ancestor and
joining the reference lets the target node share the *file* node's id, so
``{% extends "base.html.twig" %}`` becomes a real edge between two indexed
templates instead of a dangling label.

Anything not resolvable is returned unchanged and becomes a standalone node:
namespaced references (``@AcmeBundle/x.html.twig``), templates supplied by a
bundle outside the scanned tree, and dynamic names built at runtime.
"""
for parent in path.parents:
if parent.name == "templates":
candidate = parent / ref
if candidate.is_file():
return str(candidate)
break
return ref


def extract_twig(path: Path) -> dict:
"""Extract template inheritance, blocks and route references from Twig.

Nodes: the file, each referenced template, each ``{% block %}`` it defines,
and each route named by ``path()``/``url()``. Edges: ``extends``,
``includes``, ``embeds``, ``imports``, ``uses``, ``defines_block`` and
``references_route``.

``references_route`` is what ties the presentation layer back to the rest of
the graph: a route name emitted by a template resolves to the controller
that declares it, so "what renders this action, and what does it link to"
stops being invisible to the graph.
"""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"error": f"cannot read {path}"}

file_nid = _make_id(str(path))
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str(path), "source_location": "L1"}]
edges = []
seen = {file_nid}

def add(target_key: str, label: str, relation: str, offset: int) -> None:
loc = f"L{src.count(chr(10), 0, offset) + 1}"
nid = _make_id(target_key)
if nid not in seen:
seen.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
"source_file": str(path), "source_location": loc})
edges.append({"source": file_nid, "target": nid, "relation": relation,
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": loc,
"weight": 1.0})

for m in _TAG_RE.finditer(src):
ref = m.group(2)
add(_resolve(path, ref), Path(ref).name, _RELATION_BY_TAG[m.group(1)], m.start())

for m in _BLOCK_RE.finditer(src):
add(f"{path}::block::{m.group(1)}", m.group(1), "defines_block", m.start())

for expr in _EXPR_RE.finditer(src):
body, base = expr.group(0), expr.start()
for m in _FN_INCLUDE_RE.finditer(body):
ref = m.group(1)
add(_resolve(path, ref), Path(ref).name, "includes", base + m.start())
for m in _ROUTE_RE.finditer(body):
add(m.group(1), m.group(1), "references_route", base + m.start())

return {"nodes": nodes, "edges": edges}
189 changes: 189 additions & 0 deletions tests/test_twig_extraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Tests for ``.twig`` template extraction.

Twig templates carry the whole presentation layer of Symfony, Drupal, Craft and
Grav projects, and none of it reached the graph before :func:`extract_twig`: the
extension was absent from ``CODE_EXTENSIONS``, so the files were never even
walked. These tests pin the inheritance chain, the block definitions, and the
``path()``/``url()`` calls that tie a template back to its controller.
"""
from __future__ import annotations

from pathlib import Path

from graphify.detect import CODE_EXTENSIONS
from graphify.extract import _get_extractor, _make_id
from graphify.extractors.twig import extract_twig


def _write(path: Path, body: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path


def _targets(result: dict, *, relation: str | None = None) -> set[str]:
return {
str(e.get("target") or "")
for e in result.get("edges", [])
if relation is None or e.get("relation") == relation
}


def _labels(result: dict, *, relation: str | None = None) -> set[str]:
by_id = {n["id"]: str(n.get("label") or "") for n in result.get("nodes", [])}
return {by_id[t] for t in _targets(result, relation=relation) if t in by_id}


def test_twig_is_in_code_extensions():
assert ".twig" in CODE_EXTENSIONS


def test_twig_files_dispatch_to_the_twig_extractor():
assert _get_extractor(Path("templates/base.html.twig")) is extract_twig
# The double extension is the norm in Symfony; suffix matching must not care.
assert _get_extractor(Path("templates/admin/list.html.twig")) is extract_twig


def test_extends_resolves_to_the_real_template_file(tmp_path):
"""A resolved reference shares the target file's node id, forming a real edge."""
base = _write(tmp_path / "templates/base.html.twig", "<html></html>\n")
child = _write(
tmp_path / "templates/admin/list.html.twig",
'{% extends "base.html.twig" %}\n',
)
result = extract_twig(child)
assert _make_id(str(base)) in _targets(result, relation="extends")


def test_nested_reference_resolves_from_the_templates_root(tmp_path):
"""Twig resolves against the loader root, not the including file's directory."""
partial = _write(tmp_path / "templates/velzon/_topbar.html.twig", "<nav></nav>\n")
page = _write(
tmp_path / "templates/admin/desk/index.html.twig",
'{% include "velzon/_topbar.html.twig" %}\n',
)
result = extract_twig(page)
assert _make_id(str(partial)) in _targets(result, relation="includes")


def test_every_reference_tag_maps_to_its_relation(tmp_path):
page = _write(
tmp_path / "templates/page.html.twig",
"""{% extends "layout.html.twig" %}
{% include "partial.html.twig" %}
{% embed "card.html.twig" %}{% endembed %}
{% import "macros.html.twig" as m %}
{% from "forms.html.twig" import field %}
{% use "blocks.html.twig" %}
""",
)
result = extract_twig(page)
assert _labels(result, relation="extends") == {"layout.html.twig"}
assert _labels(result, relation="includes") == {"partial.html.twig"}
assert _labels(result, relation="embeds") == {"card.html.twig"}
assert _labels(result, relation="uses") == {"blocks.html.twig"}
# {% import %} and {% from %} both express a macro import.
assert _labels(result, relation="imports") == {"macros.html.twig", "forms.html.twig"}


def test_whitespace_control_markers_are_tolerated(tmp_path):
page = _write(
tmp_path / "templates/trim.html.twig",
'{%- extends "layout.html.twig" -%}\n{%- block body -%}{%- endblock -%}\n',
)
result = extract_twig(page)
assert _labels(result, relation="extends") == {"layout.html.twig"}
assert _labels(result, relation="defines_block") == {"body"}


def test_function_form_include_is_extracted(tmp_path):
page = _write(
tmp_path / "templates/fn.html.twig",
"{{ include('partial.html.twig', {foo: 1}) }}\n",
)
result = extract_twig(page)
assert _labels(result, relation="includes") == {"partial.html.twig"}


def test_blocks_are_scoped_to_their_file(tmp_path):
"""Two templates defining `content` must not collapse onto one block node."""
a = _write(tmp_path / "templates/a.html.twig", "{% block content %}{% endblock %}\n")
b = _write(tmp_path / "templates/b.html.twig", "{% block content %}{% endblock %}\n")
ta = _targets(extract_twig(a), relation="defines_block")
tb = _targets(extract_twig(b), relation="defines_block")
assert ta and tb and ta != tb


def test_path_and_url_calls_become_route_references(tmp_path):
page = _write(
tmp_path / "templates/nav.html.twig",
"""<a href="{{ path('admin_desk_index') }}">desks</a>
<a href="{{ url('admin_boutique_edit', {id: b.id}) }}">edit</a>
""",
)
result = extract_twig(page)
assert _labels(result, relation="references_route") == {
"admin_desk_index",
"admin_boutique_edit",
}


def test_function_scans_ignore_inline_script_and_style(tmp_path):
"""`path(` outside a Twig expression is page content, not a route reference."""
page = _write(
tmp_path / "templates/inline.html.twig",
"""<script>
function path(name) { return name }
path('not-a-route');
const tpl = include('not-a-template');
</script>
<a href="{{ path('real_route') }}">go</a>
""",
)
result = extract_twig(page)
assert _labels(result, relation="references_route") == {"real_route"}
assert _targets(result, relation="includes") == set()


def test_namespaced_reference_stays_unresolved_without_crashing(tmp_path):
"""`@Bundle/...` names a template outside the scanned tree; keep it as a label."""
page = _write(
tmp_path / "templates/ns.html.twig",
'{% extends "@AcmeBundle/layout.html.twig" %}\n',
)
result = extract_twig(page)
assert _labels(result, relation="extends") == {"layout.html.twig"}


def test_reference_to_a_missing_file_is_kept_as_a_label(tmp_path):
page = _write(
tmp_path / "templates/missing.html.twig",
'{% include "does/not/exist.html.twig" %}\n',
)
result = extract_twig(page)
# Unresolvable, so it is not the file id, but the edge still exists.
assert _labels(result, relation="includes") == {"exist.html.twig"}


def test_line_numbers_point_at_the_directive(tmp_path):
page = _write(
tmp_path / "templates/lines.html.twig",
'\n\n{% extends "layout.html.twig" %}\n\n{% block body %}{% endblock %}\n',
)
result = extract_twig(page)
by_label = {n["label"]: n["source_location"] for n in result["nodes"]}
assert by_label["layout.html.twig"] == "L3"
assert by_label["body"] == "L5"


def test_plain_markup_yields_only_the_file_node(tmp_path):
page = _write(tmp_path / "templates/static.html.twig", "<h1>hi</h1>\n")
result = extract_twig(page)
assert len(result["nodes"]) == 1
assert result["edges"] == []


def test_unreadable_file_reports_an_error_instead_of_raising(tmp_path):
missing = tmp_path / "templates/gone.html.twig"
result = extract_twig(missing)
assert "error" in result