# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. A tree-sitter grammar for the Django Template Language: `{% %}` tags and `{{ }}` variables. ## Commands ```sh npm run parser-generate # regenerate src/parser.c, grammar.json, node-types.json from grammar.js npm run parser-test # run the corpus tests npm run parser-build # build tree-sitter-django.wasm npm run playground # explore the grammar interactively npm install # also builds the Node binding, through node-gyp-build npm run binding-test # the Node binding's own test cargo test # the Rust binding make # the C library, and make clean to remove it npx tree-sitter test -i "Cycle With As" # one test, by name regex npx tree-sitter test --file-name cycle.txt # one corpus file npx tree-sitter test -r # force a rebuild of the parser library npx tree-sitter parse file.html # parse one file npx tree-sitter parse --debug normal f.html # lex/shift/reduce trace npx prettier --write grammar.js queries test CLAUDE.md # not `.` — see below ``` `src/parser.c`, `src/grammar.json` and `src/node-types.json` are generated **and committed**, so run `parser-generate` after every change to `grammar.js` or `src/scanner.c`. Format with prettier over specific paths rather than `npm run format` (`prettier --write .`): that also rewrites the generated `src/grammar.json` and `src/node-types.json`, which `parser-generate` then writes back, producing churn in both directions. `npm run lint` does not work: `eslint.config.mjs` was copied from a Next.js project and enables `@typescript-eslint` and `react-hooks` rules whose plugins aren't installed. Formatting is prettier's job. ### Bindings `bindings/` and the manifests beside it — `binding.gyp`, `Cargo.toml`, `pyproject.toml`, `setup.py`, `go.mod`, `Package.swift`, `Makefile`, `CMakeLists.txt` — are generated by `tree-sitter init` from the `bindings` and `metadata` sections of `tree-sitter.json`, and `.gitattributes` marks them `linguist-generated`. Do not edit them by hand: change `tree-sitter.json` and regenerate. `tree-sitter init --update` only rewrites what it considers outdated, and a changed `version` or `description` does not count, so the manifests keep the old values until they are deleted and `init` is run again. It leaves `README.md`, `package.json` and `.gitignore` alone, which is why the Node binding's `package.json` entries — `main`/`types`, the `.` export, `files`, `node-addon-api`, `node-gyp-build`, the `install` script and the optional `tree-sitter` peer — are maintained here rather than generated. Every binding was built and run on macOS: `npm install` plus `npm run binding-test`, `cargo test`, `go test ./bindings/go/`, `pip install .` with a parse, `swift build`, and `make`. The Go binding needs a `go.sum`, which `go mod tidy` writes and which is committed with the rest. `src/scanner.c` compiles without warnings, and should stay that way: cc and clang warn about `&&` within `||` (`-Wlogical-op-parentheses`), so a comparison chain written with both needs its `&&` groups parenthesized. ### CI and releasing `.github/workflows/ci.yml` runs on pull requests, and the release workflow calls it: the corpus tests, a check that `npm run parser-generate` leaves `src/` unchanged, the Node binding on Linux, macOS and Windows, and the Rust, Python, Go and Swift bindings. `.github/workflows/release.yml` runs on every push to `main` but only releases when `package.json`'s version differs from the previous commit's and no `v` tag exists yet. Then it runs CI, `prebuildify` on five runners (Linux x64 and arm64, macOS x64 and arm64, Windows x64), and a last job that tags `v`, creates the GitHub release with `tree-sitter-django.wasm` attached, and runs `npm publish --provenance`. **A release is a version-bump commit on `main`**; no other push starts one. Running the workflow by hand (`workflow_dispatch`) is the retry: it publishes whatever version `package.json` holds as long as `npm view` does not find it on the registry already, and it skips the tag and the release when a previous attempt left them behind, replacing only the Wasm asset. So a run that tagged and then failed to publish is finished by running it again, and a run against an already published version stops at the version job. Releasing needs three things to line up: - the `NPM_TOKEN` secret, a granular automation token. Provenance also needs `id-token: write`, which the publish job already declares. - **the version in `package.json` and in `tree-sitter.json`'s `metadata`**. The release trigger reads the first; the second is compiled into `src/parser.c` as the language's version, so bumping one alone leaves the parser disagreeing with the manifest. - `npm run parser-generate` after that bump, committed with it, or CI's up-to-date check fails on a two-line diff in `src/parser.c`. The arm64 Linux runner (`ubuntu-24.04-arm`) is free for public repositories only. `tree-sitter build --wasm` runs Emscripten through Docker, which the ubuntu runners provide. ### Stale parser library tree-sitter caches the compiled parser in `~/.cache/tree-sitter/lib/django.*`, and it has served a stale build after edits — producing parses that contradict the current grammar (debug traces naming tokens that no longer exist). **If a parse result disagrees with `grammar.js`, suspect this first**: rerun with `npx tree-sitter test -r`, or delete `~/.cache/tree-sitter/lib/django*`. ## Architecture ### Whitespace is syntax, not `extras` `extras: []`. Django's `Token.split_contents()` splits a tag's contents on whitespace _before_ parsing any argument, so `{% cycle 1as x %}` is the single token `1as` — an error, not `1` followed by `as`. `extras` can never be made mandatory, so the separator is an explicit token and every rule is built from helpers that place it: - `SEP` — `sym("_sep")`, the hidden rule `_sep: /[ \t\r\n]+/`, which a grammar extending this one writes as `$._sep`. Newlines included: Django matches tags with `re.DOTALL`, so tags may span lines. - `part(...)` — one tag part, taking the whitespace that separates it from the part before. - `joined(first, ...rest)` — parts separated from _each other_, with no leading separator (`not in`). - `tag(name, ...args)` — `{%`, optional separator, tag name, args, optional separator, `%}`; tags may hug their delimiters (`{%cycle 1%}`). - `asVariable($)` — the `as name` clause. Every tag that takes one names the target `field("variable", …)`; do not invent a second name for it. - `simpleTag($, name, args, kwargs)` — a tag registered with Django's `simple_tag`, which always accepts positional filter expressions, then `name=value` keywords, then `as name`. `args`: `true` (`*args`), a count (at most N), or `false`. `kwargs`: `true` (`**kwargs`), `false`, or `{name: rule}` for a known signature. Put the Python signature in a comment above the call site. - `arrangements(items)` — every ordered subset of `items`, so that a tag's options may be written in any order but never twice. Used by `translate_tag` and `include_tag`; keyword arguments are guarded by the scanner instead. Grows as `Σ C(n,k)·k!` (65 for four names), which is why it is only used for signatures that name their arguments. - `wordAsValueRules(word)` / `wordAsValue($, word, continued)` — a tag's own keyword written where a value goes. Django tells its flag from a variable by comparing the whole bit, so `reversed` is the flag of `{% for %}` while `reversed.x` and `reversed|last` are lookups; `continued` selects that second group. Needed because keyword extraction hands the parser the keyword wherever both it and `identifier` are valid, so the reading in which the word names a variable has to be built back out of it. The rules are hidden and aliased at the use site, one per level (`variable_attribute`, `value`, `filtered_value`), because an alias renames the node its own rule makes and **cannot nest one inside another** — writing it as nested inline `alias(alias(...))` calls silently yields a flat, partly zero-width tree that still generates and still parses. Check the tree, not just the accept. Consequences worth knowing before editing a rule: - **No `token.immediate` anywhere** — without extras, adjacency is already the default. Use it only to forbid whitespace somewhere a separator is otherwise allowed. - **Every optional trailing part needs somewhere for its separator to go.** Getting this wrong does not fail at generate time; it shows up as `MISSING` nodes in a passing-looking parse. - **`conflicts` entries are the normal fix, not precedence.** With explicit separators the lookahead at a part boundary is `SEP` instead of the meaningful token, so LR(1) loses its discriminator. That is why `predicate`, `binaryOperator` and `[$.library, $.load]` are listed there. Where two readings both stay valid to the end — `is not` as one operator or as `is` and a prefix `not` — the conflict needs a `prec.dynamic` alongside it to settle which wins; a static `prec` resolves the shift/reduce while the table is built and discards the other parse outright, which is how a bare `{% if x is y %}` came to be an error. ### A tag group is a sequence of blocks Every group is split so that each tag that opens a body owns a node of its own: ```js if_tag: ($) => startTag("if", part($.predicate)), if_block: ($) => seq($.if_tag, optional($.template)), endif_tag: ($) => endTag("endif"), if_group: ($) => seq( $.if_block, repeat($.elif_block), optional($.else_block), choice($.endif_tag, alias($._missing_tag, $.missing_endif_tag)), ), ``` **A block is complete without the closing tag, and that is the point.** While a template is being edited the closer usually is not there yet, and a group rule cannot be reduced without it — so `{% if a %}{% x %}` used to produce a top-level `ERROR` with the body's `template` parented to nothing that says which tag opened it. A block reduces anyway, so it survives error recovery and tooling can walk up from the cursor to find the enclosing tag. That is what makes completion of `{% elif %}`, `{% else %}`, `{% empty %}` and the end tags possible. **Every `{% %}` is a node of its own, named `_tag`** after the tag's name as Django spells it: `if_tag`, `elif_tag`, `endif_tag`, `block_tag`, `templatetag_tag`, `custom_tag`. A block is its opening tag and a body; a group is its blocks and its end tag. A tag ends at `%}` and nothing can extend it, so it reduces on whatever token follows — the end of input included — and a half-written template still holds the last `{% for … %}` or `{% endif %}` typed as a whole node, not as loose tokens. Stripping `_tag` gives the tag name; a new tag, or a tag group's new part, follows the same scheme. A group's last child is its end block or, where the end tag is missing, a zero-width marker named for it (`missing_endif_tag`; see the scanner section). So an unterminated group still reduces to a group node, and a group's tag written where no open group can hold it is an `unexpected_tag` (see the load section below), so only a body the grammar cannot read produces an `ERROR`. Every tag is built from ten inline utility rules — `simpleTagOpen`/`simpleTagClose`, `startTagOpen`/`startTagClose`, `followTagOpen`/`followTagClose`, `repeatTagOpen`/`repeatTagClose`, `endTagOpen`/`endTagClose` — through the helpers `tag` (a tag outside any group, from `simpleTagOpen`/`simpleTagClose`), `startTag`, `followTag` (a middle tag a group holds at most once: `else`, `empty`, `plural`), `repeatTag` (one it may repeat: `elif`) and `endTag`. They are inline rules rather than JS helpers so that a grammar extending this one can use them. **Every tag must begin with one of the opens**, one an extending grammar adds included: the token they read the tag's name with is valid wherever a tag's name is and the scanner always returns it, so a tag written from a bare `"{%"` never matches: its name is read as a `custom_tag` instead, with no error to show for it. Choose `followTag` or `repeatTag` by what the grammar allows, because the scanner trusts it: a tag marked once-only that can in fact repeat is an `unexpected_tag` from its second use on, and one marked repeatable that cannot is never flagged when repeated. The names themselves need no hand-maintained list either: in the generated `src/node-types.json`, a group's `children` are its block types and its end tag, and a block's children include its opening tag, so the candidates for a group are its end tag plus the opening tag of each block it can hold. For `if_group` that is `endif_tag` from the group and `if_tag`, `elif_tag`, `else_tag` from its three blocks. Going the other way — from a block the cursor sits in to the groups that can hold it — is the same table read backwards, which is what a block needs when error recovery has left it without its group. Do not reach for lookahead to solve that instead: `ts_language_next_state` follows only the shift on `{%`, which lands in the "a tag starts here" state no matter how the tree is shaped. `else_block` is shared by `if_group` and `ifchanged_group`. `comment_block` and `verbatim_block` hold raw scanner text that admits no tags. `blocktranslate_group` has one block per spelling, `blocktrans_block` and `blocktranslate_block`, because each must be closed by its own end tag; the counted form nests a `plural_block` inside it, since `{% plural %}` is only valid after `count`. The block has to know which form its opening tag was, so `blocktransRules` builds each as a hidden rule (`_blocktrans_tag`, `_blocktrans_count_tag`) and aliases both to the one visible `blocktrans_tag` where the block uses it. Their bodies are `_translate_body`, not `template`, which is why these four blocks need no `conflicts` entry — the rule below is about a body that can itself start with `{%`. **Every block needs a `conflicts` entry.** The generator offers a left associativity instead; taking it is what broke `{% for %}` in `06aa870`, where `prec.left` resolved the shift/reduce on `{%` statically and silently discarded the parse in which the body continues, so every tag inside a loop became an `ERROR`. Sharing the blocks also made the table markedly smaller — 7,200 states and a 515KB library became 5,150 and 383KB — for the same reason hoisting `blocktranslate`'s pieces into hidden rules did: the automaton stops duplicating the body states in every group's inline context. Giving every `{% %}` its own `_tag` rule shrank it again, from 1,999 states to 1,818, by the same mechanism. The group stack's tokens (see the scanner section) cost 247, to 3,304. ### Filter pipes differ by context Django parses everything between `{{` and `}}` — and the entire remainder of `{% filter %}` — as one `FilterExpression`, whose regex allows `\s*` on both sides of `|`. Every other tag splits its arguments on whitespace first, so a pipe there must be tight. Hence `_filtered_value_spaced` / `_filter_expression_spaced`, aliased back to the plain node names so the tree shape stays identical. ### Names a `{% load %}` brought in A library's tags and filters are registered in Python, so the grammar cannot know their names, their arity, or whether a tag is a block tag. Two fallback rules accept them: - `custom_tag` — `simpleTag($, $.identifier)`, i.e. what `simple_tag`/`inclusion_tag` accept, which is how a tag is registered unless it needs the parser itself. - `_custom_filter`, the last alternative of `filter`, whose name is `$.identifier` rather than one of the builtin literals. Hidden, so the tree is what an inline alternative would give, but named so that a grammar extending this one can filter it out of `filter`'s `members` the way it can `custom_tag` — see the extension section in `README.md`. **A name that only exists inside a tag group has to be reserved instead.** `endif`, `else`, `empty` and the rest are tokens only in the state their group opens, so where a tag is named the lexer reads them as an identifier and `custom_tag` takes them: `{% endif %}` on its own parsed as a tag from some library. They are listed in `NAMES_INSIDE_A_TAG_GROUP` and applied through the `tag_name` reserved context, which wraps `custom_tag`'s name **and nothing else** — a reserved context replaces the global one inside whatever it wraps, so wrapping the whole rule would un-reserve `as` and reject `{% mytag endif %}`. Adding a tag group with a new part or end tag means adding its name there too. **The same names are what `unexpected_tag` accepts.** Once reserved, a group's tag written where no open group can hold it — `{% endif %}` with nothing open, `{% else %}` in a `{% for %}` — used to be an `ERROR`, and error recovery placed it badly: every tag starts with `{%` and the read token, which the parser has already accepted when the name turns out to be wrong, so recovery rewinds to just after them and folds the stray tag into whichever tag comes next, which then holds the `ERROR`. `unexpected_tag` takes any of `NAMES_INSIDE_A_TAG_GROUP` with loose arguments, so there is nothing to recover; `prec.dynamic(-2)`, or `-4`, keeps it from displacing a tag the group does take, and `queries/errors.scm` reports it as `@error.unexpected_tag`. It added 31 states and no `conflicts` entry. **Words a tag does not take are an `unexpected_argument` inside it.** Every close utility accepts one before its scanner token, so `{% else x %}` stays the `else` of its group, `{% endif junk %}` still closes its `if` (it used to leave the whole file an `ERROR`), and `{% static "a.css" "b.css" %}` is a `static_tag`. The node is a run of hidden `_unexpected_word`s, and three details make it work: - **Lexical precedence -1.** A loose word is `token(prec(-1, /(?:[^\s%}]|\}[^\s%}])+/))`, which never holds `}}`: lexer states are shared between parse states, so the token is tried inside `{{ }}` too, and after `{{ x| ` it once swallowed the closing braces and left recovery nothing to close the variable with. Tree-sitter weighs lexical precedence before match length, so a token an argument really takes wins wherever one is valid (`b|upper` stays a filtered value) and a loose word only matches where nothing else can. - **Identifiers are words too.** Wherever a keyword is valid — after `{% for a in b`, where `reversed` may follow, or after `as`, `only`, `noop`, `silent` — tree-sitter lexes every word as an `identifier` to check for the keyword, and the loose-word token never gets a turn. So a word may also be an `identifier`, aliased to an anonymous `unexpected_word` so `queries/locals.scm` does not count it as a reference, with whatever touches it (`a=2`, `x.y|upper`, `"z"|lower:"q"`) as an immediate tail. - **-1 of dynamic precedence per word**, not per node, so a reading that flags more words than it must always loses: `{% cycle a b as x silent zqx %}` flags `zqx`, not everything after `a`. `unexpected_tag` costs -2 or -4 so that a misused tag its group does take beats reading it as a stray. A tag with a known keyword signature (`simpleTag` given names, so far only `csp_nonce_attr`) needs one more piece: the scanner's `_kwarg_name` accepts any name followed by `=` and commits the parser to a keyword argument before the name is checked, so `{% csp_nonce_attr other="b" %}` would be an `ERROR`. `simpleTag` offers `_unexpected_keyword` — an identifier and its tail, aliased to `unexpected_argument` — beside the names it knows. A separator after any argument can now also start stray words, so this needs `conflicts` entries for `unexpected_argument`, `predicate`, `_translate_option`, and `_unexpected_word` with `variable_attribute`, `resetcycle_tag` and `library`. It took the table from 3,363 states to 4,913 and the Wasm build from 251KB to 323KB. Two consequences: a repeat the scanner refuses — `{% with a=1 a=2 %}`, `{% blocktrans trimmed trimmed %}` — is reported as an unexpected argument rather than an `ERROR`; and a stray **reserved** word is still an `ERROR`, since `not`, `if`, `in`, `is`, `as`, `for` and `from` can never be an identifier: `{% trans "hi" as x as y %}` errors on the second `as`. A tag missing a required argument, such as `{% with a = 1 %}`, is an `ERROR` too; `unexpected_argument` only takes words after a tag that is otherwise complete. Where several tags share a name the group holds only once — `{% if %}{% else %}{% else %}{% else %}` — reading any of them as the stray parses, so the ties are broken by dynamic precedence: `unexpected_tag` is -2 when `_group_held` confirms the innermost group already holds the name and -4 otherwise, so each tag flagged before its time costs one more, and the first still opens the block however many follow. **Builtin names must stay keyword-extractable, or the fallbacks swallow them.** `word: $.identifier` turns each builtin's name into its own token, which the lexer prefers wherever it is valid, so a builtin commits to its own alternative and its argument rules still apply. This is why a filter's name and its `:` are separate tokens: while the name was spelled `"add:"` it was not word-shaped, so a bare `add` lexed as an identifier and reached the fallback, losing the arity check for all 20 filters that require an argument. `FILTERS_WITHOUT_ARGUMENT` / `FILTERS_WITH_ARGUMENT` / `FILTERS_WITH_OPTIONAL_ARGUMENT` carry that arity, which Django itself enforces while it parses the template ("add requires 2 arguments, 1 provided"). ### First-party libraries Django ships `i18n`, `l10n`, `static`, `cache` and `tz` as libraries rather than builtins. Their tags are modelled as ordinary tags (so far: `static`, `l10n`, `tz`, `cache` and `i18n`), and the grammar **never requires the `{% load %}`**, because an engine can preload a library through `OPTIONS: {"builtins": [...]}`, which makes `{% static "a" %}` valid with no load at all. Whether a load is present is a question about the order of nodes, not about how the template parses, so it belongs in a tool. `queries/libraries.scm` supplies the halves — `@library.` on each tag, and `@load.library` / `@load.name` on the loads — and the tool does the join, because tree-sitter queries have no ordering predicate to express it. What such a tool needs to know about Django's behaviour: - Loading is **positional**: `{% trans "x" %}{% load i18n %}` is an error, because the parser adds to its tag dict as it walks tokens. - Loading is **per file**: a `{% load %}` does not reach a template that `{% extends %}` or `{% include %}` this one. - `{% load trans from i18n %}` makes only `trans` available, not the rest of the library. A library's filters go in their own arity tables (`L10N_FILTERS_WITHOUT_ARGUMENT`, `TZ_FILTERS_WITH_ARGUMENT`, …) rather than into the builtin ones, so that the builtin parity check still has an exact list to compare against. Modelling a library name makes it keyword-extracted, so it no longer reaches `custom_tag`. A project that registers its own tag under one of these names and a different signature therefore gets a parse error. That is accepted: these names are common enough that shadowing them is the mistake. **Adding a library tag or filter touches four files**: `grammar.js`, `queries/libraries.scm` (which library it came from), `queries/highlights.scm` (the name, spelled out) and the table in `README.md`, which lists every library name the grammar models. All four are hand-maintained, and **no test catches a name left out of any of them**: `tree-sitter test` loads every `.scm` under `queries/`, so a pattern naming a node that no longer exists aborts the whole run, but a name that was never added passes 304/304 in silence, and the README nothing checks at all. The library-contents differential below, run against `django.templatetags..register`, is what finds a gap. ### External scanner (`src/scanner.c`) Twenty external tokens, for the constraints a context-free grammar cannot express: - `push_block`/`push_partial`/`push_verbatim` and the matching `pop_*` — the names in `{% block a %}…{% endblock a %}`. A push token reads the name into `pending_name`, which the group takes when `_group_open_tag_push` pushes it; a pop token checks the written name, or its absence, against the innermost open group, the only one the grammar lets it close, and `_group_close` then pops it. - `verbatim_content`, `comment_content` — raw text up to the matching close tag. - `_tag_open`, `_bt_option`, `_kwarg_name` — what a tag has already been given, so that a repeated argument is refused: `_bt_option` for `{% blocktranslate %}`'s five options, `_kwarg_name` for `name=value` arguments — those of a tag registered with `simple_tag`, and the `with` bindings of `{% with %}`, `{% include %}` and `{% blocktranslate %}` — whose names are open-ended and so are kept as names rather than as a mask. `_kwarg_name` returns nothing unless the name is followed by `=`, which is also what tells an argument apart from the `as name` clause or the end of the tag. `_tag_open` clears both, and every rule that uses either has one. Both are zero width and hidden, so no tree changes. The state is one byte of flags, not a stack, because the tag cannot nest: its body admits no tags. It lives outside the `stack`/`error` union, is cleared in both branches of `reset_scanner`, and is serialized after the status character, so a GLR stack split copies it like everything else. - `_group_open_tag_read`, `_group_open_tag_push`, `_group_follow`, `_group_repeat`, `_group_close`, `_group_held` — the stack of open groups, which the scanner keeps **without knowing any group by name**, so a grammar extending this one gets it for its own groups. The read token sits right after `{%` in every tag, always returns, and reads the tag's name into `read_word`. The other four sit right before `%}` and check that it follows: push opens a group expecting `"end"` + `read_word` (every Django end tag is spelled that way), follow records a middle tag the innermost group may hold only once, repeat records nothing, and close pops the innermost group once `read_word` is what it expects. `_group_held` belongs to `unexpected_tag` (see the load section) and returns only when the innermost group already holds a tag named `read_word`. Reading `count` (`_bt_option`) marks the group about to be pushed as waiting for `plural`, which its follow clears. An entry is `{expected, name, middles, awaits_plural}`, serialized as `expected:name:middles:flag` and a newline per entry; entries hold whole names, so serializing checks `TREE_SITTER_SERIALIZATION_BUFFER_SIZE` and goes to the error state rather than overrun it. Push and follow before `%}` cost 247 states over the table-driven scanner (3,057 to 3,304), because argument-final states stop being shared with tags where the token is not valid; after `%}`, where each tag's states are its own, saved only 15. - `_missing_tag` — the marker where a group's required tag block is missing, aliased at each use to `missing_endX_tag` or `missing_plural_tag` (`MISSING_TAGS`). It is placed only at the end of input; before the end tag of a group enclosing the innermost one; before an `endblock`/`endpartialdef` naming a block further out; or before a second `plural`, since a blocktranslate body holds no tag that could take it. Any other tag is taken to be the innermost group's, as Django's parser takes it — a middle tag it has not held yet, and a second of one it holds only once, which is an `unexpected_tag` inside it. Marking the group closed before a second `else` was tried and dropped: inside `{% block %}`, where most template code sits, there is always an enclosing group to hand the `else` to, so the inner group's own end tag became a stray instead. A counted blocktranslate is missing its plural before anything but `plural`. The marker closes the group as its end tag would (or records the plural), the group reduces with no `ERROR`, and `queries/errors.scm` reports it. Tags in a raw body are not read: the marker tells the body is raw from `verbatim_content`/`comment_content` being valid, and only the end of input counts there. **It is one token aliased per group, never a token per group**: tree-sitter does not merge parse states whose valid external tokens differ, so a token per group cloned every body state for each kind of group and took the table from 1,818 states to 17,664. A supertype would be the natural way to match every marker, but the generator silently drops one whose members are aliases, so `errors.scm` lists them. What a stack that knows no group cannot tell: a middle tag of an enclosing group that the innermost group has never held — `{% if a %}{% for x in y %}{% else %}` — is an `ERROR`, not a marker. Learning each group's middle tags as they appear was tried first and put false markers on valid nesting such as `{% if %}…{% else %}{% if %}…{% else %}`, because the outer group, having held an `else`, claimed the inner one's. - `_text_brace` — a single `{` that is text although the content regex cannot tell: before `{%` or `{#`, and at the end of input. Django splits a template with `({%.*?%}|{{.*?}}|{#.*?#})`, so a `{` is text unless one of those starts at it; `{{{` is not a text brace, since `{{` starts a variable at its first brace. `content` is a right-associative run of `_text` and `_text_brace`, so it stays one node. Inside a group's body the missing-tag check reads past `{` first, so it scans the brace itself (`scan_text_brace_rest`) rather than give up the position. - `_missing_variable_close` — the marker where a variable's `}}` is missing, aliased to `missing_variable_close`. It is placed only where the variable cannot go on — at the end of input, or before a `}` that is not `}}`, a `%` or a `{` — so `{{ hello }{% if x %}` closes the variable, reads the stray `}` as text and parses the `if` normally, where error recovery used to take the rest of the file. It is valid only after a variable's value, never where `content` is. It added 18 states. - `matcher_error` — used by no rule; returned only if the scanner reaches an error state. `check_space()` defines whitespace for the scanner and **must stay in sync with `_sep`**, because the scanner skips whitespace itself while matching names. Prefer the grammar over the scanner. A useful test: does the constraint change how the input _parses_? Block-name matching does — it decides which close tag closes what. "This keyword may not repeat" normally does not, and belongs in a linter. `blocktranslate` is the exception, and the reason is worth keeping straight: its five options are a fixed set, so uniqueness _is_ expressible — `arrangements` expresses exactly that — and the scanner is standing in for a grammar that would be correct but takes minutes to build. Where the set is open, as with `{% querystring %}`'s `**kwargs`, no grammar can express it at all, and the scanner remembers the names the tag has been given instead of a mask of known ones. Only `parse_bits` actually refuses a repeat: the `with` bindings of `{% with %}`, `{% include %}` and `{% blocktranslate %}` all go through `token_kwargs`, which builds a dict and lets the later value win. They are guarded here anyway, as a deliberate divergence — writing a name twice has no use and is a mistake worth reporting. `{% blocktranslate %}`'s `count` is not part of that, because Django keeps it in a dict of its own, so `{% blocktranslate with a=1 count a=2 %}` is accepted here as it is there. Zero-width guards carry one hazard: `recover_with_missing` can supply one without the scanner running. The `if (valid_symbols[MatcherError]) return false;` near the top of `scan` is what stops a guard being scanned during tree-sitter's mark-everything-valid recovery pass, so new guards go **below** it — and above the whitespace loop, which skips the separator the grammar still has to match. A guard must also never be valid where `content` is: a zero-width token at a content boundary preempts the internal lexer and `content` stops matching. `_missing_tag` is the one exception, and is safe only because it returns a token solely at the end of input or at `{%`, where `content` cannot match. It is scanned above the raw-text scanners, which find nothing at the end of an empty body. The group tokens were checked against that hazard with malformed groups — a missing predicate, junk before `%}`, in an end tag, in `{% else %}` and in `{% plural %}`, and a mismatched block name. Recovery never inserted one of them as `MISSING`, and the stack never fell out of step with the parser: later groups still got the right markers. Junk inside an end tag does leave the whole file an `ERROR`. ### Queries `queries/highlights.scm` lists tag and filter names explicitly, so adding either to the grammar means adding it there too; filter names appear there without their colon. A name the grammar does not know is captured through its field instead (`(filter filter_name: (identifier))`, `(custom_tag tag_name: (identifier))`). `queries/libraries.scm` is not a query editors run themselves; it is data for a linter, and every tag or filter added from a library belongs in it. `queries/locals.scm` relies on the `variable:` field to tell a binding from a reference — another reason to keep `asVariable` uniform. `{% blocktranslate %}`'s `asvar` target carries an `asvar:` field instead, because it is written inside the tag but assigned outside it: the scope on `blocktrans_block` / `blocktranslate_block` confines the `with` and `count` bindings, and would swallow `asvar` too if it were spelled the same way. `queries/exports.scm` names it for a tool to 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 block 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_block` is a scope alongside `for_block`. `queries/errors.scm` collects what an editor should report: `(ERROR)` as `@error.syntax`, `(MISSING)` as `@error.missing`, every missing-tag marker as `@error.missing_tag`, `unexpected_tag` and `unexpected_argument` as `@error.unexpected_tag` and `@error.unexpected_argument`, and `missing_variable_close` as `@error.missing_variable_close`. A tree holding only markers has no `has_error`, so a tool that checks that flag alone misses them. Adding a tag group means adding its marker there, since no supertype matches them all. ## Testing Corpus tests live in `test/corpus/*.txt`, one file per tag, and `missing.txt` for the missing-tag markers. - A test whose header carries `:error` asserts only that the parse fails. Prefer it for syntax-error cases over pinning an error-recovery tree, which is brittle. - An omitted field annotation in an expected tree is treated as "don't care"; `--show-fields` forces fields into diffs. - `test/highlights/` is **not** run by `tree-sitter test` (which looks for `test/highlight/`), and its `example.html` uses a custom `{% greet %}` tag the grammar does not model, so it parses with errors by design. ### Checking against Django itself `Pipfile.lock` pins Django (`Pipfile` itself says `django = "*"`) so the grammar can be diffed against the real implementation instead of against documentation or memory. Both checks are worth repeating whenever tags, filters or literals change: - **Builtin parity** — enumerate `Engine.default_builtins`, then compare `register.tags` and `register.filters` against `grammar.js`. For filters also compare argument arity via `inspect.signature`, ignoring the framework-injected `autoescape` parameter, which is never written in a template. Note `querystring` and `csp_nonce_attr` register as `simple_tag`, not `@register.tag`. - **Library contents** — `django.templatetags..register` gives a library's `tags` and `filters`. All of them are registered with `@register.tag`, so `inspect.signature` only reports `(parser, token)` and a signature has to come from the differential below or from reading the compile function. - **Syntax differential** — run candidate snippets through `django.template.Template()` and through `tree-sitter parse`, and compare accept/reject. This is how the `simple_tag` argument rules, the `_()` literal, and the filter-pipe spacing were pinned down. `pipenv run` has resolved to a _different_ project's virtualenv from this directory; confirm with `python -c "import django; print(django.get_version())"` and fall back to the explicit venv path. ### Deliberate divergences from Django `README.md` carries a shorter list of its own, and the two are not the same set on purpose: the README lists only the divergences where Django accepts an input that is almost certainly a mistake, which is the rule it states. Anything added here that fits that rule belongs there too; anything that does not — an unavoidable limitation, or a case where this grammar is the more permissive one — stays here. - Django has no number token: `[\w.]+` is one lexeme and `int()`/`float()` decides whether it is a literal, so `1a`, `1e`, `0x1f`, `1.2.3`, `1__0` and `1.` are variable _lookups_ there and errors here. `1.` is the one that needs care: `Variable` calls `float()` and then rejects what it parsed if the last character is a dot (`# "2." is invalid`), so a trailing dot is a number only with an exponent after it — `1.e5` is a float and `1.` is not. The `number` regex is spelled to say that, and `test/corpus/template_variable.txt` pins all three. - Django has no boolean or `None` token either. `True`, `False` and `None` are lookups, which resolve because `BaseContext._reset_dicts` seeds every context with `{"True": True, "False": False, "None": None}`. They are `boolean` and `none` literals here, so that a tool reading `queries/locals.scm` does not count them as references to something the template never defines. Shadowing still parses — `{% with True=1 %}` binds an `identifier`, because the keyword is not valid where a binding name is — but `{{ True.x }}` is an error here and an ordinary two-part lookup in Django, which renders `string_if_invalid`. - A `with` binding written twice (`{% with a=1 a=2 %}`, and the same in `{% include %}` and `{% blocktranslate %}`) is reported here, as an `unexpected_argument`, and not in Django, where `token_kwargs` builds a dict and the later value wins. It has no use, so it is treated as the mistake it almost certainly is. - `{% csp_nonce_attr "a" media="b" %}` is accepted, matching Django, which only rejects it at render time. - A misspelled tag or filter cannot be caught: `{{ x|lenght }}` is indistinguishable from a filter some library registered. Cross-referencing names against `{% load %}` needs the project's Python, so it belongs in a linter; the `load_tag` rule already parses the library and `from` names for one to use. - `{% mytag %}…{% endmytag %}` parses as two sibling `custom_tag`s. Nesting them would mean guessing that an unknown tag is a block tag. - A custom tag's contents are only assumed to be the `simple_tag` shape. `@register.tag` receives the raw token and may parse anything, so `{% mytag <<>> %}` is rejected here and not by Django. - A library may override a builtin (`Parser.add_library` is `self.filters.update(...)`), so a project that redefines `length` with a different arity gets a false error here. - Django's `static` tags ignore what follows their argument: `StaticNode.handle_token` looks for `as` two bits from the end, so `{% static "a" junk %}` and `{% static "a" as u v %}` are accepted there and reported here as an `unexpected_argument`, and `PrefixNode.handle_token` raises `IndexError` rather than `TemplateSyntaxError` on `{% get_static_prefix as %}`. - Django's `{% cache %}` takes its fragment name as the raw word in that position and finds the cache to use by testing whether the last word starts with `using=`, and only when something precedes it. So `{% cache 500 sidebar.name %}` names a fragment there, `{% cache 500 using="c" %}` names a fragment called `using="c"` rather than choosing a cache, and `{% cache 500 sidebar using= %}` takes an empty name. All three are errors here. - `partial`/`partialdef` are Django builtins as of Django 6; `elif`/`else`/`empty` are modelled as parts of their enclosing tag rather than as separate tags. - An unterminated group parses here and raises `TemplateSyntaxError` in Django: `{% if a %}x`, or a `{% for %}` closed while an `{% if %}` inside it is still open, holds a `missing_endif_tag` marker where the end tag belongs. The marker is the error, reported through `queries/errors.scm`; a check for `ERROR` nodes alone accepts the tree. - A `{{` that does not close is text in Django, whose lexer only takes a variable written `{{ … }}` on one line, so `{{ hello }` renders as written. Here it is a `template_variable` closed by a `missing_variable_close` marker, reported through `queries/errors.scm`, since a half-written variable is what it almost always is. The same lexer makes `{{ hello\n}}` text too, which this grammar accepts as a variable, because its separators allow newlines.