66import os
77import re
88import sys
9- from collections .abc import Mapping
9+ from collections .abc import Callable , Mapping
1010from dataclasses import dataclass
1111from pathlib import Path
12- from typing import TYPE_CHECKING , NamedTuple
12+ from typing import TYPE_CHECKING , Any , NamedTuple
1313
1414import black
1515import 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+
108200class 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 :
0 commit comments