From c0a2e0099cb9b19bdfa3c3b941b7c44739db6fe2 Mon Sep 17 00:00:00 2001 From: Daniel Hanson Date: Thu, 10 Sep 2026 14:38:59 -0500 Subject: [PATCH] add conditionals query --- CLAUDE.md | 13 +++++++ queries/conditionals.scm | 76 ++++++++++++++++++++++++++++++++++++++++ queries/locals.scm | 7 ++++ 3 files changed, 96 insertions(+) create mode 100644 queries/conditionals.scm diff --git a/CLAUDE.md b/CLAUDE.md index ab09c8e..eb08a3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,19 @@ too if it were spelled the same way. `queries/exports.scm` names it for a tool t bind in the scope around the block, which a locals query cannot do: it places a definition in the innermost scope containing it and offers no way out again. +`queries/conditionals.scm` is data for a tool too: `@conditional.group` / `@conditional.branch` / +`@conditional.default` say which bodies render on only some passes, so a checker can tell that +`{% if a %}{% now "Y" as n %}{% endif %}{{ n }}` may reach `{{ n }}` with nothing bound. A locals query +cannot: it matches a reference to the definition whose scope contains it and has no notion of a path not +taken. A group is exhaustive iff it has a `@conditional.default`, so each pattern captures the group +alongside one of its parts and a tool needs no list of which clause belongs to which group. Adding a tag +group whose body may be skipped means adding it there — `{% cache %}` is the non-obvious one, since a +cache hit skips the body outright and `CacheNode` pushes no context. + +A branch that is also a `@local.scope` confines its bindings whichever way the branch goes, which is why +`{% for %}` is inert for that check: `ForNode` renders `nodelist_empty` **inside** the same +`context.push()` as the loop body, so `empty_clause` is a scope alongside `for_clause`. + ## Testing Corpus tests live in `test/corpus/*.txt`, one file per tag. diff --git a/queries/conditionals.scm b/queries/conditionals.scm new file mode 100644 index 0000000..b7164d5 --- /dev/null +++ b/queries/conditionals.scm @@ -0,0 +1,76 @@ +; Which parts of a template render conditionally, for a tool checking that a +; variable is defined on every path that reaches a use of it. +; +; Django binds an "as" target straight into the context, and a tag that pushes +; no context leaves that binding visible after its group closes. So +; +; {% if a %}{% now "Y" as n %}{% endif %}{{ n }} +; +; renders the year when a is truthy and string_if_invalid ("" by default) when +; it is not — a silent empty string, not an error. queries/locals.scm cannot +; see that: a locals query matches a reference to the definition whose scope +; contains it and has no notion of a path that may not be taken. +; +; The halves a tool joins, by grouping matches on the @conditional.group node: +; +; @conditional.group the whole construct +; @conditional.branch one alternative; at most one of a group's branches +; renders on any given pass +; @conditional.default the branch that renders when no other one does +; +; A group is exhaustive iff one of its matches carries a @conditional.default. +; If it is not, some pass through it binds nothing, so every definition inside +; is conditional. If it is, a definition escapes only when every branch makes +; it. Each capture names both halves of the pair, so no ancestor walk is needed +; and no clause has to be attributed to a group by name. +; +; Branches nest, so the same test applies again to a group inside a branch. +; +; A branch that is also a @local.scope in queries/locals.scm confines its +; bindings whether or not it renders; the two queries compose and the scope +; decides first. + +; if/elif are alternatives tested in order; else runs when none matched. None +; of the three pushes a context, so this is the case the check exists for. +(if_group + [ + (if_clause) + (elif_clause) + ] @conditional.branch) @conditional.group + +(if_group + (else_clause) @conditional.default) @conditional.group + +; ifchanged renders its body only when the watched value differs from the +; previous pass, and its else otherwise. It pushes no context either. +(ifchanged_group + (ifchanged_clause) @conditional.branch) @conditional.group + +(ifchanged_group + (else_clause) @conditional.default) @conditional.group + +; The loop body runs once per item and empty runs when there are none, so the +; two partition every render. Both are inside ForNode's context.push() — the +; empty branch included, which is easy to miss — so locals.scm scopes both and +; nothing escapes either way. Named here because the construct does branch, and +; because a tool reading only this file should not have to infer that it does. +(for_group + (for_clause) @conditional.branch) @conditional.group + +(for_group + (empty_clause) @conditional.default) @conditional.group + +; A cache body is skipped entirely on a cache hit, and CacheNode pushes no +; context, so a binding made inside it reaches the rest of the template on the +; first render and is gone on the next. One branch and no default: never +; exhaustive, which is the right answer. +(cache_group + (cache_clause) @conditional.branch) @conditional.group + +; Not here, deliberately: block and partialdef bodies may also fail to render — +; a child template overrides the block, and a partial renders only where +; {% partial %} names it — but both clauses are @local.scope in locals.scm, so +; nothing they bind escapes to be checked. Bodies that always render once +; (with, filter, autoescape, spaceless, language, localize, localtime, +; timezone) are not conditional at all, and comment, verbatim and +; blocktranslate bodies hold no tags to bind anything. diff --git a/queries/locals.scm b/queries/locals.scm index 18461da..47104df 100644 --- a/queries/locals.scm +++ b/queries/locals.scm @@ -11,6 +11,13 @@ variable: (identifier) @local.definition (block_clause) @local.scope (partialdef_clause) @local.scope +; ForNode renders nodelist_empty inside the same context.push() as the loop +; body, so "{% empty %}" is part of the loop's scope even though it is a +; separate clause: "{% for x in ys %}a{% empty %}{% now "Y" as n %}{% endfor %}{{ n }}" +; is empty. queries/conditionals.scm names both clauses as branches of the +; same group and relies on this. +(empty_clause) @local.scope + ; blocktranslate pushes a context for its "with" and "count" bindings. Its ; asvar target is not one of them: it is written in the same tag but assigned ; outside it, and carries an "asvar" field rather than "variable" so that this