diff --git a/README.md b/README.md index 970ebf179..5213627c8 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/graphify/detect.py b/graphify/detect.py index c2638a0ef..846d874cf 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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'} diff --git a/graphify/extract.py b/graphify/extract.py index bc10f7d7d..067feb71a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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, @@ -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. diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..5c7710060 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -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 @@ -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, diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py new file mode 100644 index 000000000..173c9a234 --- /dev/null +++ b/graphify/extractors/twig.py @@ -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 +# +go +""", + ) + 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", "