From 79d59f8a993020149ad19ea33685ec7724bbb4bb Mon Sep 17 00:00:00 2001 From: Jean Szabo Date: Sat, 18 Jul 2026 16:51:49 +0200 Subject: [PATCH] feat(extract): add a Twig template extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twig carries the entire presentation layer of Symfony, Drupal, Craft CMS and Grav projects, and none of it reached the graph: `.twig` was absent from CODE_EXTENSIONS, so the files were never walked in the first place. On a mid-sized Symfony app that is ~143 templates — the whole admin UI — invisible to `query`/`explain`/`path`. The extractor follows the existing regex-based template extractors (blade.py, razor.py) rather than adding a grammar: - inheritance and composition: `{% extends %}`, `{% include %}`, `{% embed %}`, `{% import %}`, `{% from %}`, `{% use %}`, plus the `{{ include(...) }}` function form - `{% block name %}` definitions, scoped per file so that two templates defining `content` do not collapse onto one node - `path()` / `url()` calls as `references_route` edges That last relation is the one that pays: a route name emitted by a template resolves to the controller declaring it, so the presentation layer stops being a disconnected island in the graph. Two details worth flagging for review: - References resolve against the nearest ancestor directory named `templates`, which is the loader root convention in all four frameworks. When the target file exists, the node reuses that file's id, so `{% extends "base.html.twig" %}` becomes a real template-to-template edge instead of a dangling label. Namespaced (`@AcmeBundle/...`), out-of-tree and dynamic names stay as standalone label nodes. - `include()` / `path()` / `url()` are only scanned inside `{{ ... }}` and `{% ... %}` regions. Templates carry a lot of 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", "

hi

\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