Skip to content

Commit de59cfd

Browse files
fix: jinja[spacing] rule creating invalid syntax for minus modifiers (#5102)
Signed-off-by: Dotify71 <222976357+Dotify71@users.noreply.github.com> Co-authored-by: Dotify71 <222976357+Dotify71@users.noreply.github.com> Co-authored-by: Sathya pramod <8750601+sathyapramod@users.noreply.github.com>
1 parent b3b82aa commit de59cfd

2 files changed

Lines changed: 111 additions & 71 deletions

File tree

src/ansiblelint/rules/jinja.py

Lines changed: 109 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
import os
77
import re
88
import sys
9-
from collections.abc import Mapping
9+
from collections.abc import Callable, Mapping
1010
from dataclasses import dataclass
1111
from pathlib import Path
12-
from typing import TYPE_CHECKING, NamedTuple
12+
from typing import TYPE_CHECKING, Any, NamedTuple
1313

1414
import black
1515
import jinja2
@@ -105,6 +105,98 @@ def __str__(self) -> str:
105105
return f"{self.key}={self.value} at {self.path} fixed to {self.fixed}"
106106

107107

108+
def _is_broken_rewrite(
109+
env: jinja2.Environment, original: str, reformatted: str
110+
) -> bool:
111+
"""Return True if the reformatted text has a syntax error while the original does not."""
112+
try:
113+
env.parse(reformatted)
114+
except jinja2.exceptions.TemplateSyntaxError:
115+
try:
116+
env.parse(original)
117+
except jinja2.exceptions.TemplateSyntaxError:
118+
return False
119+
else:
120+
return True
121+
else:
122+
return False
123+
124+
125+
def _cook(value: str, *, implicit: bool = False) -> str:
126+
"""Prepare an implicit string for jinja parsing when needed."""
127+
if not implicit:
128+
return value
129+
if value.startswith("{{") and value.endswith("}}"):
130+
# maybe we should make this an error?
131+
return value
132+
return f"{{{{ {value} }}}}"
133+
134+
135+
def _uncook(value: str, *, implicit: bool = False) -> str:
136+
"""Restore an string to original form when it was an implicit one."""
137+
if not implicit:
138+
return value
139+
return value[3:-3]
140+
141+
142+
def _is_verb_token(expr_type: str | None, token: Any, verb_skipped: bool) -> bool:
143+
return bool(
144+
expr_type
145+
and expr_type.startswith("{%")
146+
and token.token_type in ("name", "whitespace")
147+
and not verb_skipped
148+
)
149+
150+
151+
def _format_expression(expr_str: str, last_token: Any) -> str:
152+
if isinstance(expr_str, str) and "\n" in expr_str:
153+
raise NotImplementedError
154+
leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip()))
155+
expr_str = leading_spaces + blacken(expr_str.lstrip())
156+
if last_token.token_type != "whitespace" and not expr_str.startswith(" "):
157+
expr_str = " " + expr_str
158+
if not expr_str.endswith(" "):
159+
expr_str += " "
160+
return expr_str
161+
162+
163+
def _process_jinja_tokens(lex: Callable[[str], Any], text: str) -> list[Token]:
164+
tokens: list[Token] = []
165+
begin_types = ("variable_begin", "comment_begin", "block_begin")
166+
end_types = ("variable_end", "comment_end", "block_end")
167+
168+
expr_str = None
169+
expr_type = None
170+
verb_skipped = True
171+
lineno = 1
172+
173+
for token in lex(text):
174+
if _is_verb_token(expr_type, token, verb_skipped):
175+
# on {% blocks we do not take first word as part of the expression
176+
tokens.append(token)
177+
if token.token_type != "whitespace":
178+
verb_skipped = True
179+
elif token.token_type in begin_types:
180+
tokens.append(token)
181+
expr_type = token.value # such {#, {{, {%
182+
expr_str = ""
183+
verb_skipped = False
184+
elif token.token_type in end_types and expr_str is not None:
185+
# process expression
186+
formatted = _format_expression(expr_str, tokens[-1])
187+
tokens.append(Token(lineno, "data", formatted))
188+
tokens.append(token)
189+
expr_str = None
190+
expr_type = None
191+
elif expr_str is not None:
192+
expr_str += token.value
193+
else:
194+
tokens.append(token)
195+
lineno = token.lineno
196+
197+
return tokens
198+
199+
108200
class JinjaRule(AnsibleLintRule, TransformMixin):
109201
"""Rule that looks inside jinja2 templates."""
110202

@@ -351,7 +443,6 @@ def unlex(
351443

352444
return result
353445

354-
# pylint: disable=too-many-locals
355446
def check_whitespace(
356447
self,
357448
text: str,
@@ -365,33 +456,13 @@ def check_whitespace(
365456
366457
:returns: (string, string, string) reformatted text, detailed error, error tag
367458
"""
368-
369-
def cook(value: str, *, implicit: bool = False) -> str:
370-
"""Prepare an implicit string for jinja parsing when needed."""
371-
if not implicit:
372-
return value
373-
if value.startswith("{{") and value.endswith("}}"):
374-
# maybe we should make this an error?
375-
return value
376-
return f"{{{{ {value} }}}}"
377-
378-
def uncook(value: str, *, implicit: bool = False) -> str:
379-
"""Restore an string to original form when it was an implicit one."""
380-
if not implicit:
381-
return value
382-
return value[3:-3]
383-
384459
# Detect original line ending style to preserve it
385460
# Only preserve \r\n (Windows CRLF), let \r (Mac classic) be normalized to \n
386461
# as per jinja 3.0.0 behavior (see test case 44)
387462
original_line_ending = None
388463
if "\r\n" in text:
389464
original_line_ending = "\r\n"
390465

391-
tokens = []
392-
details = ""
393-
begin_types = ("variable_begin", "comment_begin", "block_begin")
394-
end_types = ("variable_end", "comment_end", "block_end")
395466
implicit = False
396467

397468
# implicit templates do not have the {{ }} wrapping
@@ -405,55 +476,14 @@ def uncook(value: str, *, implicit: bool = False) -> str:
405476
)
406477
):
407478
implicit = True
408-
text = cook(text, implicit=implicit)
479+
text = _cook(text, implicit=implicit)
409480

410481
# don't try to lex strings that have no jinja inside them
411482
if not has_jinja(text):
412483
return text, "", "spacing"
413484

414-
expr_str = None
415-
expr_type = None
416-
verb_skipped = True
417-
lineno = 1
418-
try: # noqa: PLW0717
419-
for token in self.lex(text):
420-
if (
421-
expr_type
422-
and expr_type.startswith("{%")
423-
and token.token_type in ("name", "whitespace")
424-
and not verb_skipped
425-
):
426-
# on {% blocks we do not take first word as part of the expression
427-
tokens.append(token)
428-
if token.token_type != "whitespace":
429-
verb_skipped = True
430-
elif token.token_type in begin_types:
431-
tokens.append(token)
432-
expr_type = token.value # such {#, {{, {%
433-
expr_str = ""
434-
verb_skipped = False
435-
elif token.token_type in end_types and expr_str is not None:
436-
# process expression
437-
# pylint: disable=unsupported-membership-test
438-
if isinstance(expr_str, str) and "\n" in expr_str:
439-
raise NotImplementedError # noqa: TRY301
440-
leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip()))
441-
expr_str = leading_spaces + blacken(expr_str.lstrip())
442-
if tokens[
443-
-1
444-
].token_type != "whitespace" and not expr_str.startswith(" "):
445-
expr_str = " " + expr_str
446-
if not expr_str.endswith(" "):
447-
expr_str += " "
448-
tokens.append(Token(lineno, "data", expr_str))
449-
tokens.append(token)
450-
expr_str = None
451-
expr_type = None
452-
elif expr_str is not None:
453-
expr_str += token.value
454-
else:
455-
tokens.append(token)
456-
lineno = token.lineno
485+
try:
486+
tokens = _process_jinja_tokens(self.lex, text)
457487

458488
except jinja2.exceptions.TemplateSyntaxError as exc:
459489
return "", str(exc.message), "invalid"
@@ -464,12 +494,16 @@ def uncook(value: str, *, implicit: bool = False) -> str:
464494
# newlines, as we decided to not touch them yet.
465495
# These both are documented as known limitations.
466496
_logger.debug("Ignored jinja internal error %s", exc)
467-
return uncook(text, implicit=implicit), "", "spacing"
497+
return _uncook(text, implicit=implicit), "", "spacing"
468498

469499
# finalize
470500
reformatted = self.unlex(tokens, original_line_ending)
471501
failed = reformatted != text
472-
reformatted = uncook(reformatted, implicit=implicit)
502+
503+
if failed and _is_broken_rewrite(self.env, text, reformatted):
504+
return _uncook(text, implicit=implicit), "", "spacing"
505+
506+
reformatted = _uncook(reformatted, implicit=implicit)
473507
details = (
474508
f"Jinja2 template rewrite recommendation: `{reformatted}`."
475509
if failed
@@ -809,6 +843,12 @@ def test_jinja_spacing_vars(empty_rule_collection: RulesCollection) -> None:
809843
"spacing",
810844
id="46",
811845
),
846+
pytest.param(
847+
"'{ {{- item.limits -}} }'",
848+
"'{ {{- item.limits -}} }'",
849+
"spacing",
850+
id="47",
851+
),
812852
),
813853
)
814854
def test_jinja(text: str, expected: str, tag: str) -> None:

src/ansiblelint/schemas/__store__.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/play-argspec.json"
4141
},
4242
"playbook": {
43-
"etag": "09d087fd7fe850790857de197c778bbce80ec360234e3240e2eff964f773aa8c",
43+
"etag": "3a703ff75b757c2f62a9322ce67cd10f6d53e3c9b6d2a44c80f6c368b136ed3b",
4444
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/playbook.json"
4545
},
4646
"requirements": {
@@ -56,7 +56,7 @@
5656
"url": "https://raw.githubusercontent.com/ansible/ansible-rulebook/main/ansible_rulebook/schema/ruleset_schema.json"
5757
},
5858
"tasks": {
59-
"etag": "0e94cba9067e983083040bd661515801b5b1d4fd5660019f34234bba7ec8de1a",
59+
"etag": "d95fa51d1e73fa96d28ca009822e158c5cf38d5e0b2329938674da3b356a5cee",
6060
"url": "https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/tasks.json"
6161
},
6262
"vars": {

0 commit comments

Comments
 (0)