add conditionals query

This commit is contained in:
danhanson committed 2026-09-10 14:38:59 -05:00
1 parent 606bf7457f
commit c0a2e0099c
3 files changed
+96

No files matched your search

+13
View File
@@ -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.
+76
View File
@@ -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.
+7
View File
@@ -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