enforce key uniqueness on with expression, fix scanner.c

This commit is contained in:
danhanson committed 2026-09-07 20:33:43 -05:00
1 parent 6105fd9002
commit 356cc3a885
9 files changed
+53163 -53160

No files matched your search

+16 -9
View File
@@ -57,8 +57,9 @@ that place it:
positional filter expressions, then `name=value` keywords, then `as name`. `args`: `true` (`*args`), a 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 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. signature. Put the Python signature in a comment above the call site.
- `arrangements(items)` — every ordered subset of `items`, used by `simpleTag` so that known keyword - `arrangements(items)` — every ordered subset of `items`, so that a tag's options may be written in any
arguments may appear in any order but never twice. Grows as `Σ C(n,k)·k!` (65 for four names), which is order but never twice. Used by `translate` and `include`; 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. why it is only used for signatures that name their arguments.
Consequences worth knowing before editing a rule: Consequences worth knowing before editing a rule:
@@ -138,9 +139,10 @@ Twelve external tokens, for the constraints a context-free grammar cannot expres
never a second candidate name to try. Serialized as `<kind><name> ` per entry (`tag_kind_chars`). 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. - `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 - `_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 the argument is refused: `_bt_option` for `{% blocktranslate %}`'s five options, `_kwarg_name` for `name=value`
`name=value` arguments of a tag registered with `simple_tag`, whose names are open-ended and so are kept arguments — those of a tag registered with `simple_tag`, and the `with` bindings of `{% with %}`,
as names rather than as a mask. `_kwarg_name` returns nothing unless the name is followed by `=`, which `{% 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 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 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 flags, not a stack, because the tag cannot nest: its body admits no tags. It lives outside the
@@ -159,10 +161,12 @@ and the reason is worth keeping straight: its five options are a fixed set, so u
`arrangements` expresses exactly that — and the scanner is standing in for a grammar that would be correct `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 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 can express it at all, and the scanner remembers the names the tag has been given instead of a mask of
known ones. Which tags get that guard is itself a differential question: only `parse_bits` refuses a known ones. Only `parse_bits` actually refuses a repeat: the `with` bindings of
repeat, so `{% with a=1 a=2 %}`, `{% include "t" with a=1 a=2 %}` and `{% blocktranslate with a=1 a=2 %}` `{% with %}`, `{% include %}` and `{% blocktranslate %}` all go through `token_kwargs`, which builds a dict
are all accepted by Django — `token_kwargs` builds a dict and the later value simply wins — and must not and lets the later value win. They are guarded here anyway, as a deliberate divergence — writing a name
be guarded. 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 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 `if (valid_symbols[MatcherError]) return false;` near the top of `scan` is what stops a guard being scanned
@@ -216,6 +220,9 @@ documentation or memory. Both checks are worth repeating whenever tags, filters
- Django has no number token: `[\w.]+` is one lexeme and `int()`/`float()` decides whether it is a literal, - 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. so `1a`, `1e`, `0x1f`, `1.2.3`, `1__0` and `1.` are variable _lookups_ there and errors here.
- A `with` binding written twice (`{% with a=1 a=2 %}`, and the same in `{% include %}` and
`{% blocktranslate %}`) is an error here 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. - `{% 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 - 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 library registered. Cross-referencing names against `{% load %}` needs the project's Python, so it
+13 -10
View File
@@ -545,13 +545,20 @@ const django = grammar({
_translate_body: ($) => repeat1(choice($.content, $.template_variable)), _translate_body: ($) => repeat1(choice($.content, $.template_variable)),
/** /**
* The "with" option of blocktranslate and of include takes at least one of * The "with" option of blocktranslate and of include takes at least one of
* these. Django lets a name repeat here — token_kwargs builds a dict and * these. Django lets a name repeat — token_kwargs builds a dict and the
* the later value wins — so unlike a simple_tag's arguments they are not * later value simply wins — but writing one twice has no use and is taken
* guarded. * here to be a mistake, so the scanner guards these too.
*/ */
_tag_kwargs: ($) => _tag_kwargs: ($) =>
repeat1( repeat1(
part(seq(field("variable", $.identifier), "=", $.filtered_value)), part(
seq(
$._kwarg_name,
field("variable", $.identifier),
"=",
$.filtered_value,
),
),
), ),
cache_group: ($) => cache_group: ($) =>
seq( seq(
@@ -689,6 +696,7 @@ const django = grammar({
include: ($) => include: ($) =>
block( block(
"include", "include",
$._tag_open,
part($.filtered_value), part($.filtered_value),
optional( optional(
arrangements([seq(part("with"), $._tag_kwargs), part("only")]), arrangements([seq(part("with"), $._tag_kwargs), part("only")]),
@@ -825,12 +833,7 @@ const django = grammar({
), ),
with_group: ($) => with_group: ($) =>
seq( seq(
block( block("with", $._tag_open, $._tag_kwargs),
"with",
repeat1(
part(seq(field("variable", $.identifier), "=", $.filtered_value)),
),
),
optional($.template), optional($.template),
block("endwith"), block("endwith"),
), ),
+37 -38
View File
@@ -2137,6 +2137,10 @@
{ {
"type": "SEQ", "type": "SEQ",
"members": [ "members": [
{
"type": "SYMBOL",
"name": "_kwarg_name"
},
{ {
"type": "FIELD", "type": "FIELD",
"name": "variable", "name": "variable",
@@ -4690,6 +4694,10 @@
"value": "include" "value": "include"
} }
}, },
{
"type": "SYMBOL",
"name": "_tag_open"
},
{ {
"type": "SEQ", "type": "SEQ",
"members": [ "members": [
@@ -7849,37 +7857,12 @@
} }
}, },
{ {
"type": "REPEAT1", "type": "SYMBOL",
"content": { "name": "_tag_open"
"type": "SEQ", },
"members": [ {
{ "type": "SYMBOL",
"type": "PATTERN", "name": "_tag_kwargs"
"value": "[ \\t\\r\\n]+"
},
{
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "variable",
"content": {
"type": "SYMBOL",
"name": "identifier"
}
},
{
"type": "STRING",
"value": "="
},
{
"type": "SYMBOL",
"name": "filtered_value"
}
]
}
]
}
}, },
{ {
"type": "CHOICE", "type": "CHOICE",
@@ -7961,12 +7944,25 @@
}, },
"extras": [], "extras": [],
"conflicts": [ "conflicts": [
["template"], [
["predicate"], "template"
["library", "load"], ],
["_translate_option"], [
["_filtered_value_spaced"], "predicate"
["_filter_expression_spaced"] ],
[
"library",
"load"
],
[
"_translate_option"
],
[
"_filtered_value_spaced"
],
[
"_filter_expression_spaced"
]
], ],
"precedences": [], "precedences": [],
"externals": [ "externals": [
@@ -8020,7 +8016,10 @@
} }
], ],
"inline": [], "inline": [],
"supertypes": ["template_tag", "template_block_groups"], "supertypes": [
"template_tag",
"template_block_groups"
],
"reserved": { "reserved": {
"global": [ "global": [
{ {
+53012 -53073
View File
File diff suppressed because it is too large. Load diff
+17 -11
View File
@@ -65,13 +65,14 @@ typedef Array(OpenTag) Stack;
struct Scanner { struct Scanner {
bool has_error; bool has_error;
/* one bit per option of the blocktranslate tag being read, by the order of /* Everything below is readable only while has_error is false: the error
* translate_options. Kept out of the union below, whose members share their * string shares its storage with all of it.
* storage with each other. */ *
/* the "name=value" argument names the tag being read has been given, each * translate_seen holds one bit per option of the blocktranslate tag being
* followed by NAME_SEP. parse_bits refuses a repeated keyword argument, and * read, by the order of translate_options. seen_kwargs holds the
* the set of names is open, so this remembers the names rather than a mask * "name=value" argument names the tag has been given, each followed by
* of known ones. Kept out of the union, as translate_seen is. */ * NAME_SEP: parse_bits refuses a repeated keyword argument and the set of
* names is open, so the names are kept rather than a mask of known ones. */
union { union {
struct { struct {
uint8_t translate_seen; uint8_t translate_seen;
@@ -92,8 +93,11 @@ static enum TokenType pop_token_for_kind(TagKind kind) {
static void reset_scanner(struct Scanner *const scanner) { static void reset_scanner(struct Scanner *const scanner) {
if (scanner->has_error) { if (scanner->has_error) {
// sets everything to 0 values: 0, '\0', false, null /* The error shares its storage with the state below, so none of that is
memset(&scanner, 0, sizeof(scanner)); * readable here and none of it holds a pointer to free: scanner_error
* resets before it sets has_error, which is what frees them. Zeroing the
* whole struct clears the error and the state it overlays at once. */
memset(scanner, 0, sizeof(*scanner));
} else { } else {
scanner->translate_seen = 0; scanner->translate_seen = 0;
array_delete(&scanner->seen_kwargs); array_delete(&scanner->seen_kwargs);
@@ -167,9 +171,11 @@ write_serialization_error:;
char *iter = buffer; char *iter = buffer;
iter += write_code(scanner->has_error + '0', iter); iter += write_code(scanner->has_error + '0', iter);
/* Both of these overlay the error string, so they are only read when there is
* none; an error state serializes as the empty option mask and no names. */
// five options fit in five bits, which stays inside printable ASCII // five options fit in five bits, which stays inside printable ASCII
iter += write_code(scanner->translate_seen + '0', iter); iter += write_code((scanner->has_error ? 0 : scanner->translate_seen) + '0', iter);
for (unsigned i = 0; i < scanner->seen_kwargs.size; ++i) { for (unsigned i = 0; !scanner->has_error && i < scanner->seen_kwargs.size; ++i) {
int32_t code = *array_get(&scanner->seen_kwargs, i); int32_t code = *array_get(&scanner->seen_kwargs, i);
unsigned write_amt = write_code(code, iter); unsigned write_amt = write_code(code, iter);
if (write_amt == 0) { if (write_amt == 0) {
+9
View File
@@ -438,3 +438,12 @@ Blocktrans Repeating An Option Of The Tag Before It
(blocktranslate_group (blocktranslate_group
(content)) (content))
(content)) (content))
==================
Blocktrans With A Binding Twice
:error
==================
{% blocktrans with a=1 a=2 %}x{% endblocktrans %}
---
+9
View File
@@ -79,3 +79,12 @@ Csp Nonce Attr Rejects Repeated Keyword
(ERROR) (ERROR)
==================
Csp Nonce Attr With Media Twice
:error
==================
{% csp_nonce_attr media="a" media="b" %}
---
+2 -19
View File
@@ -162,31 +162,14 @@ Include With Only Before With
(content)) (content))
================== ==================
Include With A Repeated Binding Include With A Binding Twice
:error
================== ==================
{% include "t.html" with a=1 a=2 %} {% include "t.html" with a=1 a=2 %}
--- ---
(template
(content)
(include
(filtered_value
(value
(literal
(string))))
variable: (identifier)
(filtered_value
(value
(literal
(number))))
variable: (identifier)
(filtered_value
(value
(literal
(number)))))
(content))
================== ==================
Include Without A Name Include Without A Name
+48
View File
@@ -59,3 +59,51 @@ With Multiple Variables
(identifier))))) (identifier)))))
(content))) (content)))
(content)) (content))
==================
With A Binding Twice
:error
==================
{% with a=1 a=2 %}x{% endwith %}
---
==================
With A Binding Repeated Later
:error
==================
{% with a=1 b=2 a=3 %}x{% endwith %}
---
==================
With Repeating A Binding Of The Tag Before It
==================
{% with a=1 %}x{% endwith %}{% with a=2 %}y{% endwith %}
---
(template
(content)
(with_group
variable: (identifier)
(filtered_value
(value
(literal
(number))))
(template
(content)))
(with_group
variable: (identifier)
(filtered_value
(value
(literal
(number))))
(template
(content)))
(content))