20 KiB
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
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
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.
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—/[ \t\r\n]+/. Newlines included: Django matches tags withre.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).block(tag, ...args)—{%, optional separator, tag name, args, optional separator,%}; tags may hug their delimiters ({%cycle 1%}).asVariable($)— theas nameclause. Every tag that takes one names the targetfield("variable", …); do not invent a second name for it.simpleTag($, tag, args, kwargs)— a tag registered with Django'ssimple_tag, which always accepts positional filter expressions, thenname=valuekeywords, thenas name.args:true(*args), a count (at most N), orfalse.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 ofitems, so that a tag's options may be written in any order but never twice. Used bytranslateandinclude; 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.
Consequences worth knowing before editing a rule:
- No
token.immediateanywhere — 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
MISSINGnodes in a passing-looking parse. conflictsentries are the normal fix, not precedence. With explicit separators the lookahead at a part boundary isSEPinstead of the meaningful token, so LR(1) loses its discriminator. That is whypredicateand[$.library, $.load]are listed there.
A tag group is a sequence of clauses
Every group is split so that each tag that opens a body owns a node of its own:
if_clause: ($) => seq(block("if", part($.predicate)), optional($.template)),
if_group: ($) =>
seq($.if_clause, repeat($.elif_clause), optional($.else_clause), block("endif")),
A clause 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 clause 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.
The names themselves need no hand-maintained list either: in the generated src/node-types.json, a group's
children are its clause types and its own tag: field holds the end tag, so the candidates for a group
are its tag: plus the tag: of each clause it can hold. For if_group that is endif from the group and
if, elif, else from its three clauses. Going the other way — from a clause the cursor sits in to the
groups that can hold it — is the same table read backwards, which is what an unterminated tag needs, since
the group node does not exist yet.
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_clause is shared by if_group and ifchanged_group. comment_group and verbatim_group have no
clause — their bodies are raw scanner text that admits no tags — and neither does blocktranslate_group,
whose body is _translate_body.
Every clause 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 clauses 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.
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. whatsimple_tag/inclusion_tagaccept, which is how a tag is registered unless it needs the parser itself.- the last alternative of
filter, whose name is$.identifierrather than one of the builtin literals.
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.
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.<name> 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 onlytransavailable, 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.
External scanner (src/scanner.c)
Twelve external tokens, for the constraints a context-free grammar cannot express:
push_block/push_partial/push_verbatimand the matchingpop_*— name matching for{% block a %}…{% endblock a %}. They share one stack of{kind, name}entries, not one stack per kind: the grammar already guarantees nesting, so only the innermost open tag can be closed, and there is never a second candidate name to try. Serialized as<kind><name>per entry (tag_kind_chars).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_optionfor{% blocktranslate %}'s five options,_kwarg_nameforname=valuearguments — those of a tag registered withsimple_tag, and thewithbindings of{% with %},{% include %}and{% blocktranslate %}— whose names are open-ended and so are kept as names rather than as a mask._kwarg_namereturns nothing unless the name is followed by=, which is also what tells an argument apart from theas nameclause or the end of the tag._tag_openclears 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 thestack/errorunion, is cleared in both branches ofreset_scanner, and is serialized after the status character, so a GLR stack split copies it like everything else.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.
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 name: (identifier)), (custom_tag tag: (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 blocktranslate_group confines the with and count bindings, and would swallow asvar
too if it were spelled the same way. Defining it in the surrounding scope is left to the tool, since a
locals query cannot let a definition out of the scope that contains it.
Testing
Corpus tests live in test/corpus/*.txt, one file per tag.
- A test whose header carries
:errorasserts 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-fieldsforces fields into diffs. test/highlights/is not run bytree-sitter test(which looks fortest/highlight/), and itsexample.htmluses a custom{% greet %}tag the grammar does not model, so it parses with errors by design.
Checking against Django itself
The Pipfile pins 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 compareregister.tagsandregister.filtersagainstgrammar.js. For filters also compare argument arity viainspect.signature, ignoring the framework-injectedautoescapeparameter, which is never written in a template. Notequerystringandcsp_nonce_attrregister assimple_tag, not@register.tag. - Library contents —
django.templatetags.<name>.registergives a library'stagsandfilters. All of them are registered with@register.tag, soinspect.signatureonly 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 throughtree-sitter parse, and compare accept/reject. This is how thesimple_tagargument 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
- Django has no number token:
[\w.]+is one lexeme andint()/float()decides whether it is a literal, so1a,1e,0x1f,1.2.3,1__0and1.are variable lookups there and errors here. - A
withbinding written twice ({% with a=1 a=2 %}, and the same in{% include %}and{% blocktranslate %}) is an error here and not in Django, wheretoken_kwargsbuilds 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; theloadrule already parses the library andfromnames for one to use. {% mytag %}…{% endmytag %}parses as two siblingcustom_tags. 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_tagshape.@register.tagreceives the raw token and may parse anything, so{% mytag <<>> %}is rejected here and not by Django. - A library may override a builtin (
Parser.add_libraryisself.filters.update(...)), so a project that redefineslengthwith a different arity gets a false error here. - Django's
statictags ignore what follows their argument:StaticNode.handle_tokenlooks forastwo bits from the end, so{% static "a" junk %}and{% static "a" as u v %}are accepted there and rejected here, andPrefixNode.handle_tokenraisesIndexErrorrather thanTemplateSyntaxErroron{% 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 withusing=, and only when something precedes it. So{% cache 500 sidebar.name %}names a fragment there,{% cache 500 using="c" %}names a fragment calledusing="c"rather than choosing a cache, and{% cache 500 sidebar using= %}takes an empty name. All three are errors here. do_forreadsreversedoff the end of the tag before it looks forin, so{% for x in reversed %}is an error there and a loop over a variable namedreversedhere. Writing the flag as well ({% for x in reversed reversed %}) is accepted by both, which is why the name is not simply reserved in that position.do_translaterefusesasandnoopas the value of itscontextoption, so{% trans "hi" context noop %}is an error there and a context named by the variablenoophere.partial/partialdefare Django builtins as of Django 6;elif/else/emptyare modelled as parts of their enclosing tag rather than as separate tags.